Custom Metrics¶
The CustomEvalMetric lets you define your own evaluation criteria without writing metric code. You pass a list of natural-language criteria that reference data via {{placeholders}}, pick a scoring strategy, and optionally repeat the judge several times for consensus voting.
How It Works¶
- Data collection — the metric resolves every value addressable from the test case:
input,actual_output,expected_output,retrieval_context, dataset row columns,system_prompt, and anyextra_fieldsyou passed on theEvalTestCase. - Criterion filtering — each criterion must reference at least one
{{placeholder}}, and every placeholder it references must resolve. Criteria that fail either check are skipped and logged inevaluation_log["skipped_criteria"]. - Prompt build — the metric renders a DATA block listing only the values that appear in some kept criterion, followed by the criteria list.
- Judge call(s) — depending on
strategy(below), the judge either verdicts each criterion or returns one overall 0-10 score. Ifn_runs > 1, the judge is called that many times in parallel. - Aggregation — per-run outputs are combined (majority / median / mean) and, for the verdict strategy, aggregated across criteria via TCVA.
Two Scoring Strategies¶
strategy="verdict" (default)¶
The judge returns one verdict per criterion, chosen from a five-level scale:
| Verdict | Weight |
|---|---|
fully | 1.0 |
mostly | 0.9 |
partial | 0.7 |
minor | 0.3 |
none | 0.0 |
Verdict weights are aggregated into a single 0.0-1.0 score via TCVA (Temperature-Controlled Verdict Aggregation) — the same generalized power mean used by the built-in metrics.
Use this strategy when criteria are independent and you want granular per-criterion feedback in the evaluation log.
strategy="direct"¶
A single LLM-as-a-judge prompt asks the judge to rate how well the DATA satisfies all criteria together on an integer scale of 0-10, then the raw score is normalized to 0.0-1.0 (raw / 10).
Use this strategy for holistic assessments — brand voice, overall clarity, subjective quality — where separating criteria into independent verdicts is artificial.
Consensus over n_runs¶
Any strategy can be repeated n_runs times to reduce variance. When n_runs > 1:
- The judge is called
n_runstimes in parallel. - The LLM sampling temperature is automatically raised (to
0.7) so runs actually differ; a deterministic judge would make consensus voting a no-op. - Results are combined per
aggregation:
aggregation | strategy="verdict" | strategy="direct" |
|---|---|---|
majority | per-criterion mode of verdict labels | mode of the 0-10 scores |
median | per-criterion median of verdict weights | median of the 0-10 scores |
mean | per-criterion mean of verdict weights | mean of the 0-10 scores |
For verdict runs, the aggregated per-criterion weights are then passed through TCVA as usual.
n_runs is capped at 15 (safety ceiling); the default 1 means no consensus and stays fully deterministic.
Parameters¶
| Parameter | Type | Default | Description |
|---|---|---|---|
model | str | required | LLM model ("gpt-4o", "anthropic:claude-sonnet-4-0", "google:gemini-2.0-flash", "ollama:llama3", or CustomLLMClient) |
threshold | float | required | Minimum aggregated score to pass |
name | str | required | Metric name, surfaced as "Custom: <name>" |
evaluation_criteria | list[str] | required | Non-empty list of criteria; each must reference data via {{placeholders}} |
strategy | "verdict" \| "direct" | "verdict" | Scoring strategy — see above |
n_runs | int | 1 | Number of judge calls per test case (1-15). >1 enables consensus voting |
aggregation | "majority" \| "median" \| "mean" | "median" | How to combine per-run results (only used when n_runs > 1) |
temperature | float | 0.8 | TCVA aggregation temperature — NOT LLM sampling. Low (~0.1) ≈ strict/min, 0.5 ≈ arithmetic mean, high (~1.5) ≈ lenient/max. Only used by strategy="verdict" |
verbose | bool | False | Console log every result |
Available {{placeholders}}¶
| Name | Source |
|---|---|
{{input}} | EvalTestCase.input |
{{actual_output}} | EvalTestCase.actual_output |
{{expected_output}} | EvalTestCase.expected_output if set |
{{retrieval_context}} | joined EvalTestCase.retrieval_context list |
{{system_prompt}} | _meta["system_prompt"] from the connector |
| any dataset column | raw row from the connector's dataset |
| any custom field | key you put in EvalTestCase.extra_fields |
Placeholders are extracted at evaluate-time — the criteria strings themselves are reusable across test cases.
Usage¶
Basic — verdict strategy, single run¶
from eval_lib import CustomEvalMetric, EvalTestCase, evaluate
import asyncio
metric = CustomEvalMetric(
model="gpt-4o",
threshold=0.7,
name="AnswerQuality",
evaluation_criteria=[
"{{actual_output}} directly answers {{input}}",
"{{actual_output}} is factually grounded in {{retrieval_context}}",
"{{actual_output}} is concise and free of filler",
],
)
test_case = EvalTestCase(
input="Explain how garbage collection works in Python.",
actual_output="Python uses reference counting as its primary garbage collection mechanism...",
retrieval_context=[
"Python's garbage collector combines reference counting with a generational cycle detector."
],
)
results = asyncio.run(evaluate([test_case], [metric]))
Direct strategy — single holistic score¶
metric = CustomEvalMetric(
model="gpt-4o",
threshold=0.7,
name="BrandVoice",
evaluation_criteria=[
"{{actual_output}} sounds warm, confident, and helpful",
"{{actual_output}} avoids jargon a first-time customer wouldn't understand",
"{{actual_output}} matches the tone set by {{system_prompt}}",
],
strategy="direct",
)
The judge returns one integer 0-10 for the whole thing; the metric normalizes it to 0.0-1.0.
Consensus voting — 5 runs, majority vote¶
metric = CustomEvalMetric(
model="gpt-4o",
threshold=0.7,
name="MedicalInfoQuality",
evaluation_criteria=[
"{{actual_output}} contains no unsafe medical claims",
"{{actual_output}} recommends consulting a healthcare professional",
"{{actual_output}} is grounded in {{retrieval_context}}",
],
strategy="verdict",
n_runs=5,
aggregation="majority",
temperature=0.2, # TCVA — strict aggregation across criteria
)
For each criterion the judge is called 5 times; the mode verdict label wins. Then TCVA at temperature=0.2 keeps aggregation strict — any single "none" or "minor" verdict drags the final score down.
Consensus + direct — 3 runs, median score¶
metric = CustomEvalMetric(
model="anthropic:claude-sonnet-4-0",
threshold=0.7,
name="EducationalClarity",
evaluation_criteria=[
"{{actual_output}} uses concrete examples appropriate to {{input}}",
"{{actual_output}} builds concepts in a progressive order",
"{{actual_output}} defines every non-obvious term",
],
strategy="direct",
n_runs=3,
aggregation="median",
)
Median across 3 runs of the 0-10 judge score, then normalized.
Using extra_fields for domain data¶
test_case = EvalTestCase(
input="Summarize the article",
actual_output="AI is transforming healthcare...",
extra_fields={
"follow_up_questions": ["Will AI replace doctors?", "How is it regulated?"],
"target_length": 200,
},
)
metric = CustomEvalMetric(
model="gpt-4o",
threshold=0.7,
name="SummaryQuality",
evaluation_criteria=[
"{{actual_output}} is a fair summary of {{input}}",
"Each item in {{follow_up_questions}} is relevant to {{actual_output}}",
"{{actual_output}} is close in length to {{target_length}} words",
],
)
Result Fields¶
The metric returns the standard MetricPattern shape; the extra detail lives under evaluation_log:
| Key | Present when | Description |
|---|---|---|
strategy, n_runs, aggregation | always | The active configuration |
kept_criteria / skipped_criteria | always | Which criteria were scored and which were filtered out (with reason) |
data_used | always | The values shown to the judge in the DATA block |
verdicts | strategy="verdict" | Aggregated verdict + reason per kept criterion |
verdict_weights | strategy="verdict" | The numeric weight per criterion after aggregation |
per_run_detail | strategy="verdict", n_runs > 1 | Raw per-run verdict labels before aggregation |
raw_scores_0_10 | strategy="direct" | Judge scores from every run |
aggregated_raw_score_0_10 | strategy="direct" | Final raw score before normalization |
reasons, chosen_reason | strategy="direct" | Judge's justifications |
final_score | always | 0.0-1.0 score used against threshold |
Cost¶
strategy="verdict": one LLM call per run (n_runscalls total).strategy="direct": one LLM call per run (n_runscalls total).
Runs are executed concurrently via asyncio.gather, so wall-clock time is the slowest single run, not the sum.
Practical Tips¶
- Start with
n_runs=1to iterate on your criteria; enable consensus only once the criteria stabilize — it multiplies cost linearly. strategy="verdict"is better for auditability — the log shows which criterion failed.strategy="direct"is better for subjective / holistic dimensions.- Use low TCVA
temperature(0.1-0.3) for safety-critical evaluations (medical, legal, compliance) — any weak verdict drags the aggregate down. - Prefer
medianovermeanfor consensus — one outlier verdict from a stochastic judge shouldn't move the score much. - Combine with built-in metrics. Use
FaithfulnessMetricfor factual grounding,AnswerRelevancyMetricfor topical fit, andCustomEvalMetricfor domain- or brand-specific rules the built-in judges don't cover.