# Tutorial 205: Evaluations

## Prerequisites

- [Your First SFT](https://tinker-docs.thinkingmachines.ai/tutorials/basics/first-sft/)

run it interactively [source](https://github.com/thinking-machines-lab/tinker-cookbook/blob/main/tutorials/205_evaluations.py)

```
curl -O https://raw.githubusercontent.com/thinking-machines-lab/tinker-cookbook/main/tutorials/205_evaluations.py && marimo edit 205_evaluations.py
```

Tinker's evaluation system uses two abstract classes:

- **`TrainingClientEvaluator`** -- uses the training client (forward passes) to compute metrics like NLL  
- **`SamplingClientEvaluator`** -- uses the sampling client (generation) to compute metrics like accuracy

Both return `dict[str, float]` and plug into `train.Config.evaluator_builders` for automatic evaluation during training.

In this tutorial you will:

1. Implement a `TrainingClientEvaluator` that computes NLL on held-out data
2. Implement a `SamplingClientEvaluator` that samples answers and checks correctness
3. Wire evaluators into `train.Config` via `evaluator_builders`
4. Learn about the Inspect AI integration for standardized benchmarks

```
import warnings

warnings.filterwarnings("ignore", message="IProgress not found")

import tinker
import torch
from tinker import TensorData

from tinker_cookbook.eval.evaluators import (
    SamplingClientEvaluator,
    TrainingClientEvaluator,
)
from tinker_cookbook.renderers import get_renderer, get_text_content
```

## The evaluator pattern

Both evaluator types are async callables with a simple contract:

```
class TrainingClientEvaluator:
    async def __call__(self, training_client: tinker.TrainingClient) -> dict[str, float]:
        ...

class SamplingClientEvaluator:
    async def __call__(self, sampling_client: tinker.SamplingClient) -> dict[str, float]:
        ...
```

The training loop calls your evaluator periodically and logs the returned metrics. The keys become metric names (e.g., `"eval/nll"`, `"eval/accuracy"`).

## Setup

Create a training client and prepare some evaluation data.

```
api_key = mo.ui.text(kind="password", label="Paste your Tinker API key")
api_key  # noqa: B018
```

```
import os

mo.stop(
    "TINKER_API_KEY" not in os.environ and not api_key.value,
    "Paste your API key above",
)

if api_key.value:
    os.environ["TINKER_API_KEY"] = api_key.value

MODEL_NAME = "Qwen/Qwen3.5-4B"

service_client = tinker.ServiceClient()
training_client = await service_client.create_lora_training_client_async(
    base_model=MODEL_NAME, rank=16
)
tokenizer = training_client.get_tokenizer()
renderer = get_renderer("qwen3_5_disable_thinking", tokenizer)

# Prepare held-out SFT data for the NLL evaluator
eval_examples = [
    "The speed of light is approximately 3 * 10^8 meters per second.",
    "Water freezes at 0 degrees Celsius under standard pressure.",
    "The Earth orbits the Sun once every 365.25 days.",
]

eval_datums = []
for text in eval_examples:
    ids = tokenizer.encode(text)
    model_input = tinker.ModelInput.from_ints(ids[:-1])
    target_tokens = ids[1:]
    w = [1.0] * len(target_tokens)
    eval_datums.append(
        tinker.Datum(
            model_input=model_input,
            loss_fn_inputs={
                "target_tokens": TensorData.from_torch(torch.tensor(target_tokens)),
                "weights": TensorData.from_torch(torch.tensor(w)),
            },
        )
    )

print(f"Prepared {len(eval_datums)} evaluation datums")
```

Output
```
Prepared 3 evaluation datums
```

## Implementing a TrainingClientEvaluator: NLL

A `TrainingClientEvaluator` receives the current `TrainingClient` and can run forward passes to compute metrics. Here we compute the mean negative log-likelihood (NLL) on held-out data -- a standard measure of how well the model predicts the evaluation text.

```
class NLLEvaluator(TrainingClientEvaluator):
    """Compute mean NLL on held-out data using forward passes."""

def __init__(self, eval_data: list[tinker.Datum], name: str = "eval"):
        self.eval_data = eval_data
        self.name = name

async def __call__(self, training_client: tinker.TrainingClient) -> dict[str, float]:
        # Run a forward pass (no gradients) to get logprobs
        future = await training_client.forward_async(self.eval_data, loss_fn="cross_entropy")
        result = await future.result_async()

# Compute weighted mean NLL
        total_nll = 0.0
        total_tokens = 0
        for datum, output in zip(self.eval_data, result.loss_fn_outputs):
            logprobs = output["logprobs"].to_torch()
            weights = datum.loss_fn_inputs["weights"].to_torch()
            total_nll += -(logprobs * weights).sum().item()
            total_tokens += weights.sum().item()

mean_nll = total_nll / max(total_tokens, 1)
        return {f"{self.name}/nll": mean_nll}
```

```
# Test the evaluator
nll_evaluator = NLLEvaluator(eval_datums, name="held_out")
nll_metrics = await nll_evaluator(training_client)
print(f"NLL evaluation: {nll_metrics}")
```

Output
```
NLL evaluation: {'held_out/nll': 1.3247994922456288}
```

## Implementing a SamplingClientEvaluator: Accuracy

A `SamplingClientEvaluator` receives a `SamplingClient` and generates text to compute metrics. Here we sample answers to simple factual questions and check if they contain the expected answer.

```
class AccuracyEvaluator(SamplingClientEvaluator):
    """Sample answers and check if they contain the expected string."""

def __init__(self, questions_and_answers, renderer, tokenizer):
        self.qa_pairs = questions_and_answers
        self.renderer = renderer
        self.tokenizer = tokenizer

async def __call__(self, sampling_client: tinker.SamplingClient) -> dict[str, float]:
        correct = 0

for question, expected_answer in self.qa_pairs:
            messages = [{"role": "user", "content": question}]
            prompt = self.renderer.build_generation_prompt(messages)
            stop = self.renderer.get_stop_sequences()

result = await sampling_client.sample_async(
                prompt=prompt,
                sampling_params=tinker.SamplingParams(
                    max_tokens=64, temperature=0.0, stop=stop
                ),
                num_samples=1,
            )

tokens = result.sequences[0].tokens
            parsed, _ = self.renderer.parse_response(tokens)
            response_text = get_text_content(parsed).lower()

if expected_answer.lower() in response_text:
                correct += 1

accuracy = correct / len(self.qa_pairs)
        return {"eval/accuracy": accuracy, "eval/correct": float(correct)}
```

```
# Create a sampling client from current weights
sampling_client = await training_client.save_weights_and_get_sampling_client_async()

# Define test questions
test_qa = [
    ("What is the capital of Japan?", "tokyo"),
    ("What is 15 + 27?", "42"),
    ("What element has the symbol 'O'?", "oxygen"),
]

accuracy_evaluator = AccuracyEvaluator(test_qa, renderer, tokenizer)
acc_metrics = await accuracy_evaluator(sampling_client)
print(f"Accuracy evaluation: {acc_metrics}")
```

Output
```
Accuracy evaluation: {'eval/accuracy': 0.6666666666666666, 'eval/correct': 2.0}
```

## Wiring evaluators into train.Config

The `train.Config` classes in `tinker_cookbook.supervised.train` and `tinker_cookbook.rl.train` accept an `evaluator_builders` parameter. Each builder is a zero-argument callable that returns an evaluator instance.

The training loop calls each builder once at startup, then runs the evaluators periodically (controlled by `eval_every`).

### Supervised training with evaluators

```
from tinker_cookbook.supervised import train

def make_nll_evaluator():
    # Build eval data here (or capture it from outer scope)
    return NLLEvaluator(eval_datums, name="validation")

def make_accuracy_evaluator():
    return AccuracyEvaluator(test_qa, renderer, tokenizer)

config = train.Config(
    log_path="/tmp/tinker-tutorials/sft-with-evals",
    model_name="Qwen/Qwen3.5-4B",
    dataset_builder=my_dataset_builder,
    learning_rate=1e-4,

# Evaluator builders -- called once at startup, run every eval_every steps
    evaluator_builders=[make_nll_evaluator, make_accuracy_evaluator],
    eval_every=50,  # run evaluators every 50 steps
)
```

The SFT training loop detects the evaluator type automatically:

- `TrainingClientEvaluator` is called with the training client
- `SamplingClientEvaluator` is called with a sampling client created from current weights

### RL training with evaluators

The RL `train.Config` works the same way, though it only accepts `SamplingClientEvaluatorBuilder`:

```
from tinker_cookbook.rl import train

config = train.Config(
    log_path="/tmp/tinker-tutorials/rl-with-evals",
    model_name="Qwen/Qwen3.5-9B-Base",
    dataset_builder=my_rl_dataset_builder,

evaluator_builders=[make_accuracy_evaluator],
    eval_every=10,
)
```

## Built-in evaluator: NLLEvaluator

The cookbook ships a production-ready NLL evaluator at `tinker_cookbook.supervised.nll_evaluator.NLLEvaluator`. It can be constructed from a `SupervisedDataset`:

```
from tinker_cookbook.supervised.nll_evaluator import NLLEvaluator

# From a dataset object
nll_eval = NLLEvaluator.from_dataset(eval_dataset, name="test")

# Or from raw datums
nll_eval = NLLEvaluator(data=eval_datums, name="validation")
```

## Inspect AI integration

For standardized benchmarks (MMLU, GSM8K, HumanEval, etc.), the cookbook integrates with [Inspect AI](https://inspect.aisi.org.uk/). The `InspectEvaluator` wraps any Inspect task as a `SamplingClientEvaluator`:

```
from tinker_cookbook.eval.run_inspect_evals import Config

# Run Inspect evals standalone
config = Config(
    model_path="tinker://run-id/sampler_weights/final",
    # ... Inspect task configuration ...
)
```

Inspect AI provides a large library of pre-built evaluation tasks, so you can benchmark your fine-tuned model against established benchmarks without writing custom evaluation code.

## Summary

| Evaluator type | Receives | Typical metrics | Example |
| --- | --- | --- | --- |
| `TrainingClientEvaluator` | `TrainingClient` | NLL, perplexity | Forward pass on held-out data |
| `SamplingClientEvaluator` | `SamplingClient` | Accuracy, reward | Generate and grade answers |

**Key points:**

- Evaluators are async callables returning `dict[str, float]` 
- Wire them into training via `evaluator_builders` (list of zero-arg factories)
- `eval_every` controls how often they run
- Use `TrainingClientEvaluator` for metrics that only need a forward pass (fast)
- Use `SamplingClientEvaluator` for metrics that need generation (slower but more informative) 
- For standard benchmarks, use the Inspect AI integration
