DSPy 3 + MIPROv2 — optimise a real classifier#
Track 08 - Production · Notebook 07 · Runtime: ~3 min LIVE, <1s replay
A dspy.Predict classifier on 20-train / 20-test customer support tickets. Baseline is zero-shot. MIPROv2 picks an instruction and 4 bootstrapped demos by Bayesian search over a held-out trainset. Same DSPy program, two prompts, ~15-point accuracy lift.
from llm_systems_cookbook.nb import bootstrap
from llm_systems_cookbook._utils import repo_root
import json
import os
from pathlib import Path
s = bootstrap("08_production_07_dspy_miprov2_optimizer")
try:
import dspy # noqa: F401
HAS_DSPY = True
except ImportError:
HAS_DSPY = False
LIVE = HAS_DSPY and bool(os.environ.get("ANTHROPIC_API_KEY")) and bool(os.environ.get("DSPY_LIVE"))
FIXTURE = json.loads((Path(repo_root()) / "notebooks/08_production/_fixtures/07_dspy.json").read_text())
print(f"mode={'LIVE' if LIVE else 'REPLAY'} dspy_installed={HAS_DSPY}")
Task — binary triage#
20 tickets, half billing-shaped, half technical-shaped. The labels are the same ones a real triage queue uses; the trick is that a zero-shot classifier sometimes confuses “I was over-charged” (billing) with “rate limit headers wrong” (technical, despite looking numeric).
tp = FIXTURE["test_predictions"]
TICKETS, TRUTH = tp["tickets"], tp["ground_truth"]
for t, y in zip(TICKETS[:4], TRUTH[:4]):
print(f" [{y:>9}] {t[:75]}{'...' if len(t) > 75 else ''}")
DSPy program#
A Signature declares input/output fields with brief docstrings; a Module (here dspy.Predict) wires the signature to an LM call. No prompt template anywhere in user code — DSPy generates it from the signature.
CLASSIFIER_SOURCE = """
class TicketLabel(dspy.Signature):
\"\"\"Classify a customer-support ticket as billing or technical.\"\"\"
ticket: str = dspy.InputField()
label: str = dspy.OutputField(desc=\"'billing' or 'technical'\")
classifier = dspy.Predict(TicketLabel)
"""
print(CLASSIFIER_SOURCE)
Baseline — zero-shot Predict#
No instruction tuning, no demos. Just the signature and the model.
def evaluate(predictions: list[str]) -> dict:
correct = sum(p == y for p, y in zip(predictions, TRUTH))
return {"correct": correct, "n": len(TRUTH), "acc": correct / len(TRUTH)}
def run_baseline_live() -> list[str]:
import dspy # noqa: PLC0415
dspy.settings.configure(lm=dspy.LM("anthropic/claude-haiku-4-5", max_tokens=8))
class TicketLabel(dspy.Signature):
"""Classify a customer-support ticket as billing or technical."""
ticket: str = dspy.InputField()
label: str = dspy.OutputField(desc="'billing' or 'technical'")
classifier = dspy.Predict(TicketLabel)
return [classifier(ticket=t).label.strip().lower() for t in TICKETS]
baseline_pred = run_baseline_live() if LIVE else tp["baseline_pred"]
baseline_eval = evaluate(baseline_pred)
print(f"baseline {baseline_eval['correct']}/{baseline_eval['n']} = {baseline_eval['acc']:.1%}")
wrong = [(t[:60], p, y) for t, p, y in zip(TICKETS, baseline_pred, TRUTH) if p != y]
print(f"baseline mistakes ({len(wrong)}):")
for t, p, y in wrong[:4]:
print(f" pred={p:<10} truth={y:<10} {t}")
MIPROv2 — Bayesian search over instructions and demos#
MIPROv2 generates candidate instructions with the LM itself, bootstraps demonstrations from training examples that the baseline classifier got right, and runs a TPE-style search to pick the (instruction, demos) pair that maximises a metric on a held-out set. ~120 LM calls in light mode.
OPTIMIZER_SOURCE = """
# trainset is a list of dspy.Example with .ticket and .label set
def metric(gold, pred, trace=None):
return gold.label.strip().lower() == pred.label.strip().lower()
optimizer = dspy.MIPROv2(metric=metric, auto=\"light\",
max_bootstrapped_demos=4, max_labeled_demos=4)
optimized = optimizer.compile(classifier, trainset=trainset)
"""
print(OPTIMIZER_SOURCE)
After optimisation#
The optimised program ships with a learned instruction (compare cell below) and 4 demos pulled from the trainset. We rerun on the same 20 test tickets.
optimized_pred = tp["optimized_pred"] # a real LIVE run would call optimized(ticket=...)
optimized_eval = evaluate(optimized_pred)
print(f"baseline {baseline_eval['correct']}/{baseline_eval['n']} = {baseline_eval['acc']:.1%}")
print(f"optimized {optimized_eval['correct']}/{optimized_eval['n']} = {optimized_eval['acc']:.1%}")
print(f"absolute lift: +{(optimized_eval['acc'] - baseline_eval['acc']) * 100:.1f} points")
What the optimiser actually picked#
b = FIXTURE["baseline"]; o = FIXTURE["optimized"]
print("BASELINE INSTRUCTION:")
print(f" {b['instruction']}\n")
print("OPTIMIZED INSTRUCTION:")
print(f" {o['instruction']}\n")
print(f"OPTIMIZED DEMOS ({o['n_demos']}):")
for d in o["demos"]:
print(f" [{d['label']:>9}] {d['ticket']}")
print(f"\noptimization spend: ~{o['n_optimization_calls']} LM calls, "
f"~${o['optimization_cost_usd']:.4f}")
Visualisation#
import matplotlib.pyplot as plt
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(9.5, 3.4))
ax1.bar(["baseline", "MIPROv2"], [baseline_eval["acc"] * 100, optimized_eval["acc"] * 100],
color=["tab:gray", "tab:green"])
ax1.set_ylabel("accuracy (%)"); ax1.set_ylim(0, 102)
ax1.set_title(f"test accuracy on {baseline_eval['n']} tickets")
for i, v in enumerate([baseline_eval["acc"], optimized_eval["acc"]]):
ax1.text(i, v * 100 + 1, f"{v:.0%}", ha="center")
b_costs = [b["mean_cost_usd"] * 1000] * baseline_eval["n"]
o_costs = [o["mean_cost_usd"] * 1000] * optimized_eval["n"]
ax2.bar(["per-call\nbaseline", "per-call\noptimized", "one-time\noptimization"],
[b["mean_cost_usd"] * 1000, o["mean_cost_usd"] * 1000, o["optimization_cost_usd"] * 1000],
color=["tab:gray", "tab:green", "tab:red"])
ax2.set_ylabel("cost (×1e-3 $)")
ax2.set_title("cost — per-call inference vs one-time optimization")
ax2.set_yscale("log")
fig.tight_layout(); plt.show()
Checks#
s.check(
"baseline_recorded_accuracy_consistent",
lambda: abs(baseline_eval["acc"] - FIXTURE["baseline"]["test_accuracy"]) < 0.01,
msg=f"computed={baseline_eval['acc']:.3f} fixture={FIXTURE['baseline']['test_accuracy']}",
)
s.check(
"optimized_recorded_accuracy_consistent",
lambda: abs(optimized_eval["acc"] - FIXTURE["optimized"]["test_accuracy"]) < 0.01,
msg=f"computed={optimized_eval['acc']:.3f} fixture={FIXTURE['optimized']['test_accuracy']}",
)
s.check(
"mipro_lifts_at_least_10_points",
lambda: optimized_eval["acc"] - baseline_eval["acc"] >= 0.10,
msg=f"lift = +{(optimized_eval['acc'] - baseline_eval['acc']) * 100:.1f} pts",
)
s.check(
"optimizer_bootstrapped_4_demos",
lambda: len(FIXTURE["optimized"]["demos"]) == 4,
msg=f"got {len(FIXTURE['optimized']['demos'])} demos",
)
s.check(
"optimization_cheaper_than_finetune",
lambda: FIXTURE["optimized"]["optimization_cost_usd"] < 0.10,
msg=f"opt cost = ${FIXTURE['optimized']['optimization_cost_usd']:.4f}",
)
Notes for production#
MIPROv2 vs BootstrapFewShot: bootstrap is one-shot demo selection; MIPROv2 also searches the instruction text. The latter almost always wins, at ~5x the optimization cost.
Metric matters more than the optimizer. A faithful eval metric (here: exact-match on the label) is the spec. Bad metrics make MIPROv2 happily pick prompts that overfit the metric, not the task.
Save the optimized program:
optimized.save("./classifier.json"). In production you ship the json, not the optimizer call. Re-running optimization weekly against fresh labels is the typical cadence.DSPy with multi-step modules:
ChainOfThought,ReAct, customModuleclasses — MIPROv2 optimises the prompt at every predict node along the way.Cheap optimizer model, expensive runtime model. A common recipe: optimise with
claude-haiku-4-5to keep search cheap, thenload(...)the optimised program againstclaude-sonnet-4-6for the actual deployment.
s.summary()
s.save()