Skip to content

Add LLM-as-a-judge to eval suite and bump NDD version - #2324

Open
sarahyurick wants to merge 16 commits into
NVIDIA-NeMo:mainfrom
sarahyurick:llm_judge
Open

Add LLM-as-a-judge to eval suite and bump NDD version#2324
sarahyurick wants to merge 16 commits into
NVIDIA-NeMo:mainfrom
sarahyurick:llm_judge

Conversation

@sarahyurick

@sarahyurick sarahyurick commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Things to consider:

  • Add an agent skills file for how to write the YAML and Jinja files?
  • Right now I made it text specific, with JSONL/Parquet input/output. Some other modalities are possible via NDD's LLMJudgeColumnConfig.multi_modal_context (e.g., ImageContext) but this still requires using DocumentBatch (since Curator's DataDesignerStage is document input/output only).
  • This can be combined with Curator's Slurm array partitioning feature. It should be easy to get an agent to do it but we can also just add it to the eval/llm_judge directory directly.
  • Resumability and retry support too. Like with my small test run, there were 192 input records but 189 output records. Likely an NDD refusal or error for some reason. Do we care about getting every single judgement?
  • List of LLMs to recommend as judges
  • Multiple LLMs doing the same judging task. If the LLMs agree about a record then it is "easier"/more confident, but if the LLMs disagree then more analysis/user insight is needed.
  • How to handle empty examples
  • How to truncate according to token limits. It depends on the judging pipeline that the user wants to run but can we provide good inputs/recommendations? (Opened a Linear issue to track it as a follow-up: https://linear.app/nvidia/issue/NMCUR-402/enhance-llm-judge-eval-to-filtermodify-by-token-count-limits)
  • vLLM shutdown issue when I Ctrl+C a run. Not sure if it is machine dependent.

Signed-off-by: Sarah Yurick <sarahyurick@gmail.com>
@copy-pr-bot

copy-pr-bot Bot commented Aug 20, 2026

Copy link
Copy Markdown

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

Comment thread eval/llm_judge/generic_text_judge.py Outdated
Comment thread eval/llm_judge/generic_text_judge.py Outdated
Comment thread eval/llm_judge/generic_text_judge.py Outdated
Comment thread eval/llm_judge/generic_text_judge.py Outdated
if not isinstance(execution, dict):
raise ValueError("'execution' must be a mapping when provided.")
mode = execution.get("mode", "single_stage")
if mode not in {"single_stage", "multi_stage"}:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure if splitting up each judge task into its own NDD stage is worth it, or if it should all stay within the same NDD stage. Both are possible. Not sure it matters though.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping both for now. I think single_stage is more in-line with existing Curator + NDD patterns, but multi_stage is nice if each individual judges need their own runtime_env and if we want to use resumability between judging stages.

files_per_partition: int | None,
) -> Pipeline:
"""Build one streaming Curator reader → optional language gate → NDD stages → filters → writer pipeline."""
# TODO: Add an optional TokenLengthFilter stage before NDD stages so prompts

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Big TODO. Right now I just do string truncation in the jinja file. But we should decide the best course of action.

Also, since some extractors might return null while others return content, we should decide whether those examples are evaluated or not? Right now I force the empty string and still give it to the judge.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread eval/llm_judge/README.md Outdated
Comment thread pyproject.toml
# Synthetic Data Generation (SDG) Dependencies
sdg_cpu = [
"data-designer==0.5.5",
"data-designer==0.9.1",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using this version for my tests. It is only like 2 commits behind NDD main right now.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cc @ayushdg if you have any opinions about these changes?

Comment thread eval/llm_judge/cc_extract_example/text_extraction_qwen_judge.yaml
Comment thread eval/llm_judge/examples/text_extraction_judge.yaml Outdated
extract_reasoning_content: false
scores:
- name: best_extraction
description: Select the candidate that is most useful as clean document text.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had Codex generate all the Jinja, scoring, etc. prompts. Just a POC for now.

@VibhuJawa
VibhuJawa self-requested a review August 20, 2026 04:11
Comment thread eval/llm_judge/generic_text_judge.py Outdated
Comment thread eval/llm_judge/generic_text_judge.py Outdated
Comment thread eval/llm_judge/generic_text_judge.py Outdated
sarahyurick and others added 11 commits August 20, 2026 10:38
Signed-off-by: Sarah Yurick <sarahyurick@gmail.com>
Signed-off-by: Sarah Yurick <sarahyurick@gmail.com>
Signed-off-by: Sarah Yurick <sarahyurick@gmail.com>
Signed-off-by: Sarah Yurick <sarahyurick@gmail.com>
Signed-off-by: Sarah Yurick <sarahyurick@gmail.com>
Signed-off-by: Sarah Yurick <sarahyurick@gmail.com>
Signed-off-by: Sarah Yurick <sarahyurick@gmail.com>
Signed-off-by: Sarah Yurick <sarahyurick@gmail.com>
Signed-off-by: Sarah Yurick <sarahyurick@gmail.com>
Signed-off-by: Sarah Yurick <sarahyurick@gmail.com>
Signed-off-by: Sarah Yurick <sarahyurick@gmail.com>
Comment thread eval/llm_judge/run_llm_judge.py

@ayushdg ayushdg left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor comments around dependencies. Thanks a lot for the bump!

Comment thread pyproject.toml
"kaldiio; sys_platform == 'never'",
"levenshtein; sys_platform == 'never'",
"numba==0.65.0", # Override RAPIDS/legacy caps for the inference image; vLLM 0.22 requires numba 0.65.0
"packaging>=25", # Required by data-designer==0.9.1; overrides Lightning's packaging<25 cap from nemo-toolkit[asr]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add a followup ticket to bump asr. I think some of these overrides can be avoided.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread pyproject.toml Outdated
"xgrammar>=0.1.32", # Override vllm's ==0.1.29 pin to address CVE GHSA-7rgv-gqhr-fxg3 (DoS via multi-layer nesting)
"sqlfluff>=4.2.0", # Address CVE-2026-46373/46374 (parser DoS); overrides data-designer-engine==0.5.5 sqlfluff<4 cap
"pandas>=3.0.0, <= 3.0.4" # Override data-designers constraint of <3
"sqlfluff>=4.2.0", # Address CVE-2026-46373/46374 (parser DoS); data-designer-engine==0.9.1 supports sqlfluff>=4.1,<5

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can probably get rid of this and do uv lock --upgrade-package sqlfluff to have uv pick up a newer version rather than needing an override now.

Comment thread uv.lock Outdated
[[package]]
name = "cryptography"
version = "50.0.0"
version = "49.0.0"

@ayushdg ayushdg Aug 26, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we also override cryptography. Looks like NDD adds a pin but should work with newer versions. It's just following older conventions of keeping an upper pin: https://github.com/NVIDIA-NeMo/DataDesigner/pull/821/changes.
>=50 is recommended for https://github.com/NVIDIA-NeMo/Curator/security/dependabot/217.

We can also open an issue in NDD to remove the upper pin on cryptography

Comment thread uv.lock Outdated
[[package]]
name = "gitpython"
version = "3.1.58"
version = "3.1.57"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we know if a specific package is downgrading this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure. Looks like the latest is 3.1.60. I can do uv lock --upgrade-package GitPython.

Signed-off-by: Sarah Yurick <sarahyurick@gmail.com>
Signed-off-by: Sarah Yurick <sarahyurick@gmail.com>

@VibhuJawa VibhuJawa left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still understanding the workflow. Left an initial review . I wonder if we should do pairwise comparisons as a first pass vs score based judges ?

Comment thread eval/llm_judge/README.md

## Input and output

The runner does not require a fixed text schema. A prompt can reference any fields present in an input JSONL or Parquet row. Keep a stable identifier such as `document_id` or `track_id` when you need to join results to another dataset.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is a track_id ?

Comment thread eval/llm_judge/README.md
}
```

NDD adds one top-level column for each judge. A judge named `extraction_quality` with a `quality` score produces a result shaped like this:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quick question: Is each judge 1 LLM call or multiple LLM calls ?

Comment thread eval/llm_judge/README.md
--local-dir /path/to/Qwen3.8-27B
```

A model that serves fine can still be a bad judge, and the only way to know is to run it. The prompt asks the model in plain language to answer with one of a fixed set of values, like "answer with exactly one of: yes, no, unclear." The pipeline checks that answer against the schema afterward. It doesn't stop the model from answering wrong in the first place — it just drops any row where the answer doesn't match. So a model that doesn't follow instructions well shows up as missing rows, not wrong scores.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The thing that helped me identify bad judges if we swap_candidates and results change/hold.

I wonder if we should make that a first class citizen ?

Comment thread eval/llm_judge/README.md

## Analyzing results

Running the same rubric through multiple LLMs turns judge agreement into a signal, not just a sanity check: where the models agree, the record is likely easy and the score can be trusted with less scrutiny; where they disagree, look into why before trusting the rubric or filter at scale. A disagreement can mean the record is genuinely ambiguous or hard to score — evidence for a human-in-the-loop or an `unresolved`-style rubric option — or it can mean the prompt or rubric wording is too vague or underspecified for a model to apply consistently, which calls for tightening the prompt rather than trusting either score.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is also my lived experience

Comment thread eval/llm_judge/README.md

For the optional FastText language gate, provide `--language`, `--fasttext-langid-model-path`, and optionally `--min-langid-score` and `--language-text-field`. Omitting `--language` skips the stage and does not require FastText.

Press `Ctrl-C` once to cancel a local Dynamo run and allow normal Ray and inference-server cleanup to finish. If cancellation interrupts cleanup, inspect remaining model-server subprocesses before starting another run.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dont think this is how we/our customers run things. Probably good to clean it up. We should try to keep this llm judge relevant.

Comment on lines +52 to +57
def _load_yaml(path: Path) -> dict[str, object]:
with path.open(encoding="utf-8") as file:
config = yaml.safe_load(file)
if not isinstance(config, dict):
msg = f"Judge config must contain a mapping: {path}"
raise TypeError(msg)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we move this to utils or something ? Feels like a util for us to upstream



def _place_filters(config: dict[str, object], stages: list[dict[str, object]]) -> list[list[dict[str, object]]]:
"""Place top-level filters after the NDD stage that produces their judge column."""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For my understanding: What is the use of place filters produces their judge column

Comment on lines +119 to +127
def _get_num_workers(config: dict[str, object], *, owner: str) -> int | None:
"""Return an optional fixed Ray worker count for one NDD stage."""
num_workers = config.get("num_workers")
if num_workers is None:
return None
if isinstance(num_workers, bool) or not isinstance(num_workers, int) or num_workers <= 0:
msg = f"{owner} must be a positive integer."
raise ValueError(msg)
return num_workers

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe move to a common util or something ?

Comment on lines +179 to +208
def _build_language_filter_stage(
*,
language: str | None,
model_path: str | None,
min_score: float,
text_field: str,
) -> ScoreFilter | None:
"""Build an optional FastText language gate without retaining its score column."""
if not language:
return None
if not model_path:
msg = "--fasttext-langid-model-path is required when --language is provided."
raise ValueError(msg)
if not 0.0 <= min_score <= 1.0:
msg = "--min-langid-score must be between 0 and 1."
raise ValueError(msg)

# FastText is optional, so import it only for jobs that enable this stage.
from nemo_curator.stages.text.filters.fasttext import FastTextLangId

return ScoreFilter(
filter_obj=FastTextLangId(
model_path=model_path,
min_langid_score=min_score,
lang=language,
),
text_field=text_field,
verbose=True,
).with_(name="fasttext_language_filter")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I get the motivation for this but i wonder if we want it as a generic feature here ?

Comment on lines +469 to +470
judge_stages = []
for stage, filters_after_stage in zip(configured_stages, stage_filters, strict=True):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the intuition b/w using 1 stage vs multiple stages , do we expect perf to be better b/w them ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants