Open in Colab

DSPy and MIPROv2 — prompts as parameters#

Track 04 - Agents · Notebook 04 · Runtime: ~5s on CPU

Prerequisites: 04_agents/02 (structured outputs).

Papers: Khattab et al. 2023 (2310.03714), Opsahl-Ong et al. 2024 (MIPROv2 — 2406.11695).

DSPy treats the prompt text as a parameter. You declare a Signature, and an optimiser (MIPROv2 is the current default) searches over instruction strings and demo subsets to maximise a task metric.

To make the search behaviour visible without running a real DSPy compile loop (~120 LM calls), we replace the LM with a deterministic policy stand-in: a function predict_with_policy(text, instruction, demos) whose output depends mechanically on the policy implied by the instruction and the demo distribution. We then exhaustively score the 9-cell grid of (instruction × demo-pool) pairs and verify a small TPE-style sampler recovers the optimum.

For the same task against a real DSPy 3 program with claude-haiku-4-5, see 08_production/07_dspy_miprov2_optimizer.

from llm_systems_cookbook.nb import bootstrap

from dataclasses import dataclass

import numpy as np

s = bootstrap("04_agents_04_dspy_3_miprov2")

Task: classify sentences as positive / neutral / negative#

A deterministic “LLM” scores each sentence with a rule: count positive / negative lexicon words; the effective accuracy depends on the instruction + demo pool given to it.

rng = np.random.default_rng(0)

POS = {"love", "great", "excellent", "amazing", "wonderful", "fantastic", "good", "happy"}
NEG = {"terrible", "awful", "bad", "horrible", "worst", "hate", "disappointing"}


def true_label(text: str) -> str:
    toks = text.lower().split()
    p = sum(1 for t in toks if t in POS)
    n = sum(1 for t in toks if t in NEG)
    if p > n:
        return "positive"
    if n > p:
        return "negative"
    return "neutral"


# Mix of clearly-labeled sentences and tied sentences. Tied sentences
# are where the instruction + demo pool matter — the "specific" instruction
# handles them by falling back to neutral, while "misleading" biases them
# toward positive.
CLEAR_POS = ["I love this product, it is amazing.",
             "A wonderful fantastic experience, great value.",
             "Good food, happy staff, excellent night."]
CLEAR_NEG = ["The food was terrible and service was awful.",
             "Horrible and disappointing. The worst.",
             "Bad weather ruined the awful trip."]
TIED_NEUTRAL = [
    "Great but also horrible.",
    "Love it, hate it.",
    "Good and bad in equal measure.",
    "Amazing but awful.",
    "Excellent and terrible in one package.",
    "Happy and disappointing ending.",
]
PURE_NEUTRAL = ["It was ok, neither strong nor weak.",
                "Nothing to write home about, just average.",
                "Just there. Did the job. So-so."]

EXAMPLES = (CLEAR_POS + CLEAR_NEG + TIED_NEUTRAL + PURE_NEUTRAL) * 2
rng.shuffle(EXAMPLES)
LABELS = [true_label(t) for t in EXAMPLES]

The policy stand-in#

Given an instruction + demos + query, return a label. The instruction selects a tie-break policy (“on ties, output neutral” vs “on ties, output positive”); the demo pool’s class distribution can override it. The mapping is mechanical and fully deterministic — that’s what makes the search trajectory below interpretable.

INSTRUCTIONS = {
    "vague":    "Decide the sentiment of the sentence.",
    "specific": "Label each sentence with exactly one of: positive, neutral, negative. Count positive/negative words before deciding; if they tie, output neutral.",
    "misleading": "Always prefer the most intense emotion label. When unsure, output positive.",
}

DEMO_POOLS = {
    "balanced": [
        ("I love this.", "positive"),
        ("This is terrible.", "negative"),
        ("It is ok.", "neutral"),
    ],
    "skewed": [
        ("I love this.", "positive"),
        ("Great food.", "positive"),
        ("Wonderful.", "positive"),
    ],
    "empty": [],
}


_lm_rng = np.random.default_rng(0)


def lm_predict(text: str, instruction: str, demos: list[tuple[str, str]]) -> str:
    '''Simulated LM: the instruction governs the tie-break policy.

    - The *specific* instruction teaches the LM to output neutral on
      ties; accurate.
    - The *vague* instruction leaves ties to a random tie-break -
      models without clear guidance just guess.
    - The *misleading* instruction biases ties toward positive.
    Demo pools further shift the tie-break via implicit anchoring.
    '''
    toks = text.lower().split()
    p = sum(1 for t in toks if t in POS)
    n = sum(1 for t in toks if t in NEG)

    policy = "random"
    if "count positive/negative words" in instruction.lower():
        policy = "neutral"
    elif "prefer the most intense" in instruction.lower():
        policy = "positive"

    if demos:
        pos_frac = sum(1 for _, lbl in demos if lbl == "positive") / len(demos)
        neg_frac = sum(1 for _, lbl in demos if lbl == "negative") / len(demos)
        # Balanced demos + vague instruction = the balanced tie-break lands on neutral.
        if policy == "random" and pos_frac < 0.6 and neg_frac < 0.6 and pos_frac > 0 and neg_frac > 0:
            policy = "neutral"
        # Skewed demos override even the specific instruction slightly.
        if pos_frac > 0.6:
            policy = "positive"

    if p > n:
        return "positive"
    if n > p:
        return "negative"
    if policy == "neutral":
        return "neutral"
    if policy == "positive":
        return "positive"
    if policy == "negative":
        return "negative"
    # Random tie-break (degraded behaviour without guidance or demos).
    return str(_lm_rng.choice(["positive", "negative", "neutral"]))


def evaluate(instruction_name: str, demos_name: str) -> float:
    instr = INSTRUCTIONS[instruction_name]
    demos = DEMO_POOLS[demos_name]
    # Reset the LM's random tie-break stream for reproducibility across
    # (instruction, demos) evaluations.
    global _lm_rng
    _lm_rng = np.random.default_rng(0)
    correct = sum(lm_predict(t, instr, demos) == y for t, y in zip(EXAMPLES, LABELS, strict=True))
    return correct / len(EXAMPLES)

Exercises#

  1. Real DSPy. pip install dspy-ai, define a dspy.Signature, point it at a real LM via dspy.LM("anthropic/claude-haiku-4-5"), and run MIPROv2.compile. Compare the optimised instruction against the one you’d write by hand.

  2. Add an axis. Extend the grid with temperature {0, 0.3, 0.7}. Grid search gets expensive; TPE earns its keep.

  3. Bootstrapped demos. Replace the hand-written demo pool with demos generated by running a teacher LM on training data and keeping the ones it gets right.

References#

  • DSPy 3 docs and dspy.teleprompt.MIPROv2 source — the proposer and k-shot sampler are each ~200 lines.

  • 08_production/07_dspy_miprov2_optimizer — the same machinery against a real model with a real ~$0.014 optimization budget.

s.summary()
s.save()