
Dspy Copro
- 5 installs
- 11 repo stars
- Updated June 28, 2026
- lebsral/dspy-programming-not-prompting-lms-skills
Helps with ai & agent building tasks.
About
dspy-copro is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- dspy-copro
- AI & Agent Building
- AI-coding skill
Dspy Copro by the numbers
- 5 all-time installs (skills.sh)
- Ranked #13,065 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/lebsral/dspy-programming-not-prompting-lms-skills --skill dspy-coproAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 5 |
|---|---|
| repo stars | ★ 11 |
| Last updated | June 28, 2026 |
| Repository | lebsral/dspy-programming-not-prompting-lms-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
Instruction Optimization with dspy.COPRO
Guide the user through using dspy.COPRO to automatically generate, evaluate, and select the best instructions for their DSPy program's signatures.
What is COPRO
dspy.COPRO (Collaborative Prompting) is a DSPy optimizer that improves your program by finding better instructions for each signature. Instead of you hand-writing prompt instructions, COPRO generates many candidate instructions, evaluates each one against your metric, and keeps the best.
Key properties:
- Generates instruction candidates -- uses an LM to propose alternative instructions for each predictor in your program
- Evaluates each candidate -- scores every candidate against your metric on the training set
- Iterates in depth -- runs multiple rounds, using top performers from round N to inform candidates in round N+1
- Tunes instructions and prefixes -- optimizes both the signature docstring (instruction) and output field prefixes
- Works with any program -- optimizes all predictors in a program sequentially
When to use COPRO
Use dspy.COPRO when:
- You want to systematically search for better instructions rather than hand-tuning prompts
- You have a metric and 20-200 training examples
- Your program has one or a few predictors that need better instructions
- You want to explore a wide range of instruction phrasings (use high
breadth)
Do not use COPRO when:
- You also want to optimize few-shot examples -- use
dspy.MIPROv2instead (it tunes both instructions and demos) - You have very few examples (<20) and want a lightweight optimizer -- use
dspy.GEPAinstead - You want to fine-tune model weights -- use
dspy.BootstrapFinetune - You just need few-shot examples without instruction changes -- use
dspy.BootstrapFewShot
Basic usage
Three things are needed: a program, a metric, and training data.
import dspy
dspy.configure(lm=dspy.LM("openai/gpt-4o-mini")) # or "anthropic/claude-sonnet-4-5-20250929", etc.
# 1. Define a program
classify = dspy.ChainOfThought("text -> label")
# 2. Define a metric
def metric(example, prediction, trace=None):
return prediction.label.lower() == example.label.lower()
# 3. Prepare training data
trainset = [
dspy.Example(text="Love this product!", label="positive").with_inputs("text"),
dspy.Example(text="Terrible experience.", label="negative").with_inputs("text"),
# ... 20-200 examples
]
# 4. Optimize with COPRO
optimizer = dspy.COPRO(
metric=metric,
breadth=10,
depth=3,
)
optimized = optimizer.compile(
classify,
trainset=trainset,
eval_kwargs=dict(num_threads=4, display_progress=True),
)
# 5. Use the optimized program
result = optimized(text="The quality exceeded my expectations.")
print(result.label)Constructor parameters
dspy.COPRO(
prompt_model=None, # LM for generating candidates (defaults to configured LM)
metric=None, # Evaluation function (required)
breadth=10, # Number of candidates per iteration (must be >1)
depth=3, # Number of optimization iterations
init_temperature=1.4, # Temperature for candidate generation
track_stats=False, # Collect optimization statistics
)| Parameter | Type | Default | Description |
|---|---|---|---|
prompt_model | dspy.LM | None | LM used to generate instruction candidates. If None, uses the globally configured LM |
metric | Callable | None | Scoring function with signature (example, prediction, trace=None) -> float/bool. Required |
breadth | int | 10 | Number of candidate instructions generated per iteration. Higher = wider search, more LM calls |
depth | int | 3 | Number of optimization rounds. Each round refines candidates from the previous round |
init_temperature | float | 1.4 | Temperature for generating candidates. Higher = more diverse candidates |
track_stats | bool | False | When True, collects per-iteration statistics (max, average, min, std dev of scores) |
Compile method
optimized = optimizer.compile(
student, # Program to optimize (modified in-place)
trainset=trainset, # Training examples
eval_kwargs={}, # Extra kwargs for dspy.Evaluate
)| Parameter | Type | Description |
|---|---|---|
student | dspy.Module | The program to optimize. COPRO modifies it in-place and also returns it |
trainset | list[dspy.Example] | Training examples for evaluating candidates |
eval_kwargs | dict | Passed to dspy.Evaluate -- commonly num_threads, display_progress, display_table |
The returned program has additional metadata:
optimized.candidate_programs-- dict of all evaluated candidates with their scoresoptimized.total_calls-- total LM API calls made during optimization
The breadth parameter
breadth controls how many instruction candidates COPRO generates per iteration. It is the most important tuning knob.
| Breadth | Candidates per round | Total candidates (depth=3) | Use case |
|---|---|---|---|
| 5 | 4 new + 1 base | ~15 | Quick test, cheap |
| 10 (default) | 9 new + 1 base | ~30 | Good balance |
| 20 | 19 new + 1 base | ~60 | Thorough search |
| 50 | 49 new + 1 base | ~150 | Exhaustive, expensive |
The first iteration generates breadth - 1 new candidates from the base instruction. Subsequent iterations generate new candidates informed by the best performers so far.
Cost note: Each candidate is evaluated on the full trainset, so total LM calls scale as breadth * depth * len(trainset). With breadth=10, depth=3, and 100 training examples, expect roughly 3,000 evaluation calls plus candidate generation calls.
How COPRO generates candidates
COPRO follows a seeding-and-refinement loop:
1. Seed phase (iteration 0): Takes the existing instruction from each signature. Generates breadth - 1 alternative instructions using temperature-controlled sampling from the prompt model.
2. Evaluate phase: Scores every candidate instruction by swapping it into the program and running the metric against the full training set. Duplicate (instruction, prefix) pairs are skipped.
3. Refine phase (iterations 1 through depth-1): Takes the top-performing candidates from the previous round. Generates new candidates informed by what worked and what did not.
4. Multi-predictor handling: When a program has multiple predictors, COPRO optimizes them sequentially. It locks in the best instruction for predictor 1 before moving to predictor 2, so later predictors benefit from earlier improvements.
5. Selection: After all iterations, the instruction with the highest metric score is selected for each predictor.
Tracking optimization statistics
Enable track_stats=True to see how candidates perform across iterations:
optimizer = dspy.COPRO(
metric=metric,
breadth=15,
depth=3,
track_stats=True,
)
optimized = optimizer.compile(
my_program,
trainset=trainset,
eval_kwargs=dict(num_threads=4),
)When track_stats is enabled, COPRO logs per-iteration statistics including max, average, min, and standard deviation of candidate scores. This helps you understand whether the search is converging or whether more breadth/depth would help.
Using a separate prompt model
You can use a stronger (or cheaper) LM specifically for generating instruction candidates:
# Use a strong model to generate candidates, evaluate with the production model
candidate_lm = dspy.LM("openai/gpt-4o") # or "anthropic/claude-sonnet-4-5-20250929", etc.
production_lm = dspy.LM("openai/gpt-4o-mini") # or "anthropic/claude-haiku-4-5-20251001", etc.
dspy.configure(lm=production_lm)
optimizer = dspy.COPRO(
prompt_model=candidate_lm,
metric=metric,
breadth=10,
depth=3,
)
optimized = optimizer.compile(my_program, trainset=trainset, eval_kwargs={})This is useful when you want a capable model to brainstorm instructions but evaluate and run with a cheaper model.
Comparison with GEPA and MIPROv2
| Aspect | COPRO | GEPA | MIPROv2 |
|---|---|---|---|
| What it tunes | Instructions + prefixes | Instructions | Instructions + few-shot demos |
| Search strategy | Breadth-first candidate generation | Evolutionary (genetic programming) | Bayesian optimization |
| Data needed | 20-200 examples | 20-100 examples | 50-500 examples |
| Key parameter | breadth (candidates per round) | Population/generations | auto ("light"/"medium"/"heavy") |
| Cost | Moderate (breadth depth trainset evals) | Low-moderate | Moderate-high |
| Best for | Exploring many instruction variants | Few examples, feedback-driven instruction tuning | Best overall prompt optimization |
When to pick COPRO over alternatives:
- You want explicit control over the search process (breadth, depth, temperature)
- You want to inspect all candidate instructions and their scores
- You care about instructions specifically, not few-shot examples
- You want a middle ground between GEPA (feedback-driven, competitive with MIPROv2) and MIPROv2 (heavyweight)
When to pick MIPROv2 instead:
- You want the best overall results (MIPROv2 optimizes both instructions and demos)
- You prefer an
autosetting over manual tuning of search parameters - You have enough data (200+) for MIPROv2 to shine
When to pick GEPA instead:
- You have very few examples (<50)
- You want evolutionary search rather than breadth-first generation
- You want something lightweight and fast
Gotchas
- Claude sets `breadth=1` which silently breaks optimization.
breadthmust be greater than 1 — with breadth=1 there are no alternative candidates to evaluate. Use at least breadth=5 for a meaningful search. - Claude forgets that `compile()` modifies the student in-place. Unlike most optimizers, COPRO mutates the program you pass to
compile(). If you need the original program for baseline comparison, clone it first or create a fresh instance before callingcompile(). - Claude passes `eval_kwargs` as positional instead of keyword. The
compile()signature iscompile(student, *, trainset, eval_kwargs)—trainsetandeval_kwargsare keyword-only. Always useoptimizer.compile(program, trainset=trainset, eval_kwargs={}). - Claude uses COPRO when MIPROv2 would be better. COPRO only optimizes instructions. If the task also benefits from few-shot demonstrations (most tasks do), MIPROv2 optimizes both and typically outperforms COPRO. Use COPRO when you specifically want instruction-only optimization or need to inspect all candidate instructions.
- Claude skips the `eval_kwargs` parameter. COPRO requires
eval_kwargsto be passed tocompile(), even if empty. Omitting it causes a TypeError. Always includeeval_kwargs={}oreval_kwargs=dict(num_threads=4).
Additional resources
- COPRO API docs
- reference.md — constructor parameters, compile method, candidate inspection
- examples.md — worked examples with breadth search and configuration comparison
Cross-references
Install any skill: npx skills add lebsral/DSPy-Programming-not-prompting-LMs-skills --skill <name>- Improving accuracy end-to-end (metrics, evaluation, optimizer selection) -- see
/ai-improving-accuracy - MIPROv2 for combined instruction + demo optimization -- see
/ai-improving-accuracy - GEPA for lightweight instruction tuning -- see
/ai-improving-accuracy - Writing evaluation metrics -- see
/dspy-evaluate - Preparing training data -- see
/dspy-data - Signatures and instructions -- see
/dspy-signatures - For worked examples, see examples.md
- Install `/ai-do` if you do not have it — it routes any AI problem to the right skill and is the fastest way to work:
npx skills add lebsral/DSPy-Programming-not-prompting-LMs-skills --skill ai-do
[
{
"prompt": "My classifier instructions are mediocre and I want to automatically find better ones. I have 100 labeled examples. How do I use COPRO?",
"expected_output": "Sets up dspy.COPRO with metric, breadth, and depth to search for better instructions",
"assertions": [
"Uses dspy.COPRO with metric, breadth, and depth parameters",
"Calls optimizer.compile(program, trainset=trainset, eval_kwargs=...) with keyword arguments",
"Shows how to evaluate before and after optimization",
"Does NOT set breadth=1 (must be >1)"
]
},
{
"prompt": "I ran COPRO but want to see what instructions it tried. How do I inspect the candidates?",
"expected_output": "Accesses candidate_programs on the optimized program to inspect all tried instructions and their scores",
"assertions": [
"Accesses optimized.candidate_programs to get all candidates",
"Shows how to sort candidates by score to find top performers",
"Mentions total_calls for cost tracking",
"Suggests track_stats=True for per-iteration statistics"
]
},
{
"prompt": "Should I use COPRO or MIPROv2 for my optimization? I want both better instructions and few-shot examples.",
"expected_output": "Recommends MIPROv2 since it optimizes both instructions and demos, while COPRO only optimizes instructions",
"assertions": [
"Explains that COPRO only optimizes instructions, not few-shot demos",
"Recommends MIPROv2 for combined instruction + demo optimization",
"Notes when COPRO is still preferred (instruction-only control, candidate inspection)",
"Does NOT recommend COPRO for the combined case"
]
}
]
dspy.COPRO Examples
Example 1: Instruction optimization with breadth search
A sentiment classification pipeline where COPRO searches across many instruction candidates to find the phrasing that maximizes accuracy. Demonstrates tuning breadth and inspecting candidate results.
import dspy
from dspy.evaluate import Evaluate
dspy.configure(lm=dspy.LM("openai/gpt-4o-mini")) # or "anthropic/claude-sonnet-4-5-20250929", etc.
# Define a classification signature with an initial instruction
class ClassifySentiment(dspy.Signature):
"""Classify the sentiment of the given text."""
text: str = dspy.InputField()
sentiment: str = dspy.OutputField(desc="One of: positive, negative, neutral")
# Build the program
classify = dspy.ChainOfThought(ClassifySentiment)
# Prepare training and dev data
trainset = [
dspy.Example(text="This product changed my life for the better!", sentiment="positive").with_inputs("text"),
dspy.Example(text="Worst purchase I have ever made.", sentiment="negative").with_inputs("text"),
dspy.Example(text="It works as described, nothing special.", sentiment="neutral").with_inputs("text"),
dspy.Example(text="Absolutely love the build quality.", sentiment="positive").with_inputs("text"),
dspy.Example(text="Broke after two days of normal use.", sentiment="negative").with_inputs("text"),
dspy.Example(text="Average product for the price point.", sentiment="neutral").with_inputs("text"),
dspy.Example(text="Five stars, exceeded all expectations!", sentiment="positive").with_inputs("text"),
dspy.Example(text="Customer support was unhelpful and rude.", sentiment="negative").with_inputs("text"),
dspy.Example(text="Shipping was on time, product is okay.", sentiment="neutral").with_inputs("text"),
dspy.Example(text="I recommend this to everyone I know.", sentiment="positive").with_inputs("text"),
# ... add more examples for better results (50-200 recommended)
]
devset = [
dspy.Example(text="The fabric feels cheap but the design is nice.", sentiment="neutral").with_inputs("text"),
dspy.Example(text="Never buying from this brand again.", sentiment="negative").with_inputs("text"),
dspy.Example(text="My kids love it, great gift idea!", sentiment="positive").with_inputs("text"),
]
# Define the metric
def sentiment_match(example, prediction, trace=None):
return prediction.sentiment.strip().lower() == example.sentiment.strip().lower()
# Evaluate the baseline (before optimization)
evaluator = Evaluate(devset=devset, metric=sentiment_match, num_threads=4)
baseline_score = evaluator(classify)
print(f"Baseline score: {baseline_score}")
# Optimize with COPRO -- wide breadth to explore many instruction variants
optimizer = dspy.COPRO(
metric=sentiment_match,
breadth=20, # Generate 19 candidates + 1 base per round
depth=3, # 3 rounds of refinement
init_temperature=1.4, # Diverse candidate generation
track_stats=True, # Log per-iteration statistics
)
optimized = optimizer.compile(
classify,
trainset=trainset,
eval_kwargs=dict(num_threads=4, display_progress=True),
)
# Evaluate the optimized program
optimized_score = evaluator(optimized)
print(f"Optimized score: {optimized_score}")
print(f"Improvement: {optimized_score - baseline_score}")
# Inspect what instructions COPRO tried
for predictor_name, candidates in optimized.candidate_programs.items():
print(f"\n--- Candidates for {predictor_name} ---")
# Sort by score to see top performers
sorted_candidates = sorted(candidates, key=lambda c: c["score"], reverse=True)
for i, candidate in enumerate(sorted_candidates[:5]):
print(f"\n #{i+1} (score: {candidate['score']:.2f})")
print(f" Instruction: {candidate['instruction'][:120]}...")
# Use the optimized program
result = optimized(text="The battery life is phenomenal, best I have seen in years.")
print(f"\nPrediction: {result.sentiment}")
print(f"Reasoning: {result.reasoning}")
# Save for production
optimized.save("optimized_classifier.json")What this demonstrates:
- Wide breadth search (20) -- generates 19 alternative instructions per round, increasing the chance of finding a high-performing phrasing
- Tracking statistics --
track_stats=Truelogs per-iteration performance to monitor convergence - Inspecting candidates -- after optimization,
candidate_programscontains every instruction tried and its score, letting you understand what worked - Baseline comparison -- always evaluate before and after to confirm the optimization actually helped
- Class-based signature -- the initial instruction ("Classify the sentiment of the given text.") is the docstring, which COPRO replaces with better alternatives
Example 2: Comparing COPRO candidates across configurations
Run COPRO with different breadth settings to understand the cost-quality tradeoff. This pattern helps you decide on the right breadth for your task before committing to a full optimization run.
import dspy
from dspy.evaluate import Evaluate
dspy.configure(lm=dspy.LM("openai/gpt-4o-mini")) # or "anthropic/claude-sonnet-4-5-20250929", etc.
# A question-answering program
class AnswerQuestion(dspy.Signature):
"""Answer the question based on general knowledge."""
question: str = dspy.InputField()
answer: str = dspy.OutputField(desc="A concise factual answer")
# Training and dev data
trainset = [
dspy.Example(question="What is the capital of France?", answer="Paris").with_inputs("question"),
dspy.Example(question="Who wrote Romeo and Juliet?", answer="Shakespeare").with_inputs("question"),
dspy.Example(question="What planet is closest to the Sun?", answer="Mercury").with_inputs("question"),
dspy.Example(question="What is the chemical symbol for gold?", answer="Au").with_inputs("question"),
dspy.Example(question="In what year did World War II end?", answer="1945").with_inputs("question"),
# ... add more for reliable results
]
devset = [
dspy.Example(question="What is the largest ocean?", answer="Pacific").with_inputs("question"),
dspy.Example(question="Who painted the Mona Lisa?", answer="Leonardo da Vinci").with_inputs("question"),
dspy.Example(question="What is the boiling point of water in Celsius?", answer="100").with_inputs("question"),
]
def answer_match(example, prediction, trace=None):
return example.answer.lower() in prediction.answer.lower()
evaluator = Evaluate(devset=devset, metric=answer_match, num_threads=4)
# Compare different breadth settings
configs = [
{"breadth": 5, "depth": 2, "label": "narrow (breadth=5, depth=2)"},
{"breadth": 10, "depth": 3, "label": "default (breadth=10, depth=3)"},
{"breadth": 25, "depth": 3, "label": "wide (breadth=25, depth=3)"},
]
results = []
for config in configs:
print(f"\n{'='*60}")
print(f"Running: {config['label']}")
print(f"{'='*60}")
# Fresh program for each run
program = dspy.ChainOfThought(AnswerQuestion)
optimizer = dspy.COPRO(
metric=answer_match,
breadth=config["breadth"],
depth=config["depth"],
track_stats=True,
)
optimized = optimizer.compile(
program,
trainset=trainset,
eval_kwargs=dict(num_threads=4, display_progress=True),
)
score = evaluator(optimized)
total_calls = optimized.total_calls
results.append({
"label": config["label"],
"score": score,
"total_calls": total_calls,
"optimized_program": optimized,
})
print(f"Score: {score:.1f}% | LM calls: {total_calls}")
# Print comparison table
print(f"\n{'='*60}")
print(f"{'Config':<40} {'Score':>8} {'LM Calls':>10}")
print(f"{'-'*60}")
for r in results:
print(f"{r['label']:<40} {r['score']:>7.1f}% {r['total_calls']:>10}")
# Show the winning instruction from the best config
best = max(results, key=lambda r: r["score"])
print(f"\nBest config: {best['label']} ({best['score']:.1f}%)")
# Inspect the winning instruction
for predictor_name, candidates in best["optimized_program"].candidate_programs.items():
top = max(candidates, key=lambda c: c["score"])
print(f"\nBest instruction for {predictor_name}:")
print(f" \"{top['instruction']}\"")
print(f" Score: {top['score']:.2f}")What this demonstrates:
- Breadth comparison -- runs the same task with breadth=5, 10, and 25 to show the tradeoff between search coverage and cost
- Cost tracking --
total_callsshows how many LM calls each configuration used, making the cost difference concrete - Fresh program per run -- creates a new
dspy.ChainOfThoughtfor each configuration to ensure a fair comparison - Extracting the winning instruction -- after comparing configs, inspects the best-scoring instruction to see what COPRO found
- Practical decision-making -- this pattern helps you choose the right breadth setting for your task before committing to a production optimization run
Condensed from dspy.ai/api/optimizers/COPRO/. Verify against upstream for latest.
dspy.COPRO — API Reference
Constructor
dspy.COPRO(
prompt_model=None, # LM | None
metric=None, # Callable | None
breadth=10, # int (must be >1)
depth=3, # int
init_temperature=1.4, # float
track_stats=False, # bool
)| Parameter | Type | Default | Description |
|---|---|---|---|
prompt_model | `dspy.LM | None` | None |
metric | `Callable | None` | None |
breadth | int | 10 | Number of candidate instructions generated per iteration. Must be >1. Higher = wider search, more LM calls. |
depth | int | 3 | Number of optimization rounds. Each round refines candidates from the previous round. |
init_temperature | float | 1.4 | Temperature for generating candidates. Higher = more diverse candidates. |
track_stats | bool | False | When True, collects per-iteration statistics (max, average, min, std dev of scores). |
compile()
optimized = optimizer.compile(
student, # dspy.Module (required, modified in-place)
*,
trainset, # list[dspy.Example] (required, keyword-only)
eval_kwargs, # dict (required, keyword-only)
)| Parameter | Type | Description |
|---|---|---|
student | dspy.Module | The program to optimize. Modified in-place and also returned. |
trainset | list[dspy.Example] | Training examples for evaluating candidates. |
eval_kwargs | dict | Passed to dspy.Evaluate. Common keys: num_threads, display_progress, display_table. |
Note: trainset and eval_kwargs are keyword-only arguments (after *). Always pass them as trainset=..., eval_kwargs=....
Returns: The optimized program (same object as student) with additional attributes:
| Attribute | Type | Description |
|---|---|---|
candidate_programs | dict | All evaluated candidates with their scores, keyed by predictor name. |
total_calls | int | Total number of LM API calls made during optimization. |
get_params()
optimizer.get_params() -> dict[str, Any]Returns the optimizer's configuration parameters as a dictionary.
Optimization process
1. Seed phase (iteration 0): Takes existing instruction from each signature. Generates breadth - 1 alternative instructions using temperature-controlled sampling. 2. Evaluate phase: Scores every candidate by swapping it into the program and running the metric against the full training set. Duplicate (instruction, prefix) pairs are skipped. 3. Refine phase (iterations 1 through depth-1): Generates new candidates informed by the best performers from previous rounds. 4. Multi-predictor handling: Optimizes predictors sequentially — locks in the best instruction for predictor 1 before moving to predictor 2. 5. Selection: After all iterations, the instruction with the highest metric score is selected for each predictor.
Cost estimation
Total evaluation calls ≈ breadth * depth * len(trainset) per predictor, plus candidate generation calls.
| breadth | depth | trainset size | Approx. eval calls |
|---|---|---|---|
| 5 | 2 | 50 | ~500 |
| 10 | 3 | 100 | ~3,000 |
| 25 | 3 | 100 | ~7,500 |
| 50 | 3 | 200 | ~30,000 |