Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
c6fb042
feat: make numpy an optional dependency
isaacbmiller Apr 22, 2026
40d5c92
Update dspy/teleprompt/utils.py
isaacbmiller Apr 22, 2026
1c8a95b
fix(ci): add numpy to dev and test_extras
isaacbmiller Apr 22, 2026
787a37c
refactor: move _numpy helper under dspy/utils/
isaacbmiller Apr 22, 2026
1b10976
refactor: generalize numpy helper into require_optional
isaacbmiller May 8, 2026
e3e7183
refactor: replace gratuitous numpy with stdlib math in mipro/infer_rules
isaacbmiller May 8, 2026
0224c48
fix(_optional): only suggest dspy[extra] when caller asserts it exists
isaacbmiller May 8, 2026
23e0b7a
refactor(_optional): centralize dspy-extras registry, drop per-call e…
isaacbmiller May 8, 2026
b078a23
refactor: replace _optional with lazy_import, stdlib math in teleprom…
isaacbmiller May 12, 2026
39a8f84
refactor: replace np.log2 with math.log2 in gepa auto_budget
isaacbmiller May 12, 2026
5bd8647
fix: skip tomllib test on Python < 3.11
isaacbmiller May 12, 2026
b7fc979
refactor: replace numpy.std with statistics.pstdev in copro_optimizer
isaacbmiller May 12, 2026
1b3224c
refactor: vendor lazy_loader pattern into require() with _MissingModu…
isaacbmiller May 13, 2026
ccee0e3
docs: add numpy install notes to API pages and tutorials
isaacbmiller May 13, 2026
330361d
refactor: detect dspy/dspy-ai dist for install hints, move math impor…
isaacbmiller May 13, 2026
efecfc9
test(lazy_import): tighten brittle lazy_import tests
isaacbmiller May 13, 2026
ddc1ac3
docs(lazy_import): switch RST double backticks to mkdocs single backt…
isaacbmiller May 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions dspy/clients/embedding.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
from typing import Any, Callable
from __future__ import annotations

from typing import TYPE_CHECKING, Any, Callable

import litellm
import numpy as np

from dspy.clients.cache import request_cache
from dspy.utils.lazy_import import require

if TYPE_CHECKING:
import numpy as np


class Embedder:
Expand Down Expand Up @@ -104,6 +109,7 @@ def _preprocess(self, inputs, batch_size=None, caching=None, **kwargs):
return input_batches, caching, merged_kwargs, is_single_input

def _postprocess(self, embeddings_list, is_single_input):
np = require("numpy")
embeddings = np.array(embeddings_list, dtype=np.float32)
if is_single_input:
return embeddings[0]
Expand Down
5 changes: 3 additions & 2 deletions dspy/predict/knn.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import numpy as np

from dspy.clients import Embedder
from dspy.primitives import Example
from dspy.utils.lazy_import import require


class KNN:
Expand Down Expand Up @@ -36,6 +35,7 @@ def __init__(self, k: int, trainset: list[Example], vectorizer: Embedder):
similar_examples = knn(input="hello")
```
"""
np = require("numpy")
self.k = k
self.trainset = trainset
self.embedding = vectorizer
Expand All @@ -46,6 +46,7 @@ def __init__(self, k: int, trainset: list[Example], vectorizer: Embedder):
self.trainset_vectors = self.embedding(trainset_casted_to_vectorize).astype(np.float32)

def __call__(self, **kwargs) -> list:
np = require("numpy")
Comment thread
isaacbmiller marked this conversation as resolved.
Outdated
input_example_vector = self.embedding([" | ".join([f"{key}: {val}" for key, val in kwargs.items()])])
scores = np.dot(self.trainset_vectors, input_example_vector.T).squeeze()
nearest_samples_idxs = scores.argsort()[-self.k :][::-1]
Expand Down
16 changes: 13 additions & 3 deletions dspy/retrievers/embeddings.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
from __future__ import annotations

import json
import os
from typing import Any

import numpy as np
from typing import TYPE_CHECKING, Any

from dspy.utils.lazy_import import require
from dspy.utils.unbatchify import Unbatchify

if TYPE_CHECKING:
import numpy as np


class Embeddings:
"""DSPy Embeddings retriever.
Expand Down Expand Up @@ -56,6 +60,7 @@ def forward(self, query: str):
return dspy.Prediction(passages=passages, indices=indices)

def _batch_forward(self, queries: list[str]):
np = require("numpy")
q_embeds = self.embedder(queries)
q_embeds = self._normalize(q_embeds) if self.normalize else q_embeds

Expand All @@ -65,6 +70,7 @@ def _batch_forward(self, queries: list[str]):
return self._rerank_and_predict(q_embeds, pids)

def _build_faiss(self):
np = require("numpy")
nbytes = 32
partitions = int(2 * np.sqrt(len(self.corpus)))
dim = self.corpus_embeddings.shape[1]
Expand All @@ -91,6 +97,7 @@ def _faiss_search(self, query_embeddings: np.ndarray, num_candidates: int):
return self.index.search(query_embeddings, num_candidates)[1]

def _rerank_and_predict(self, q_embeds: np.ndarray, candidate_indices: np.ndarray):
np = require("numpy")
candidate_embeddings = self.corpus_embeddings[candidate_indices]
scores = np.einsum("qd,qkd->qk", q_embeds, candidate_embeddings)

Expand All @@ -105,6 +112,7 @@ def _rerank_and_predict(self, q_embeds: np.ndarray, candidate_indices: np.ndarra
return results

def _normalize(self, embeddings: np.ndarray):
np = require("numpy")
norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
return embeddings / np.maximum(norms, 1e-10)

Expand Down Expand Up @@ -132,6 +140,7 @@ def save(self, path: str):
json.dump(config, f, indent=2)

# Save embeddings
np = require("numpy")
np.save(os.path.join(path, "corpus_embeddings.npy"), self.corpus_embeddings)

# Save FAISS index if it exists
Expand Down Expand Up @@ -187,6 +196,7 @@ def load(self, path: str, embedder):
self.embedder = embedder

# Load embeddings
np = require("numpy")
self.corpus_embeddings = np.load(embeddings_path)

# Load FAISS index if it was saved and FAISS is available
Expand Down
10 changes: 4 additions & 6 deletions dspy/teleprompt/copro_optimizer.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
import statistics
from collections import defaultdict

import dspy
Expand Down Expand Up @@ -142,9 +143,6 @@ def compile(self, student, *, trainset, eval_kwargs):
id(p): {"depth": [], "max": [], "average": [], "min": [], "std": []} for p in module.predictors()
}

if self.track_stats:
import numpy as np

candidates = {}
evaluated_candidates = defaultdict(dict)

Expand Down Expand Up @@ -254,7 +252,7 @@ def compile(self, student, *, trainset, eval_kwargs):
results_latest[id(p_old)]["max"].append(max(latest_scores))
results_latest[id(p_old)]["average"].append(sum(latest_scores) / len(latest_scores))
results_latest[id(p_old)]["min"].append(min(latest_scores))
results_latest[id(p_old)]["std"].append(np.std(latest_scores))
results_latest[id(p_old)]["std"].append(statistics.pstdev(latest_scores))

# Now that we've evaluated the candidates, set this predictor to the best performing version
# to ensure the next round of scores reflect the best possible version
Expand Down Expand Up @@ -296,7 +294,7 @@ def compile(self, student, *, trainset, eval_kwargs):
results_best[id(p_base)]["max"].append(max(scores))
results_best[id(p_base)]["average"].append(sum(scores) / len(scores))
results_best[id(p_base)]["min"].append(min(scores))
results_best[id(p_base)]["std"].append(np.std(scores))
results_best[id(p_base)]["std"].append(statistics.pstdev(scores))

for i in range(shortest_len - 1, -1, -1):
# breakpoint()
Expand Down Expand Up @@ -341,7 +339,7 @@ def compile(self, student, *, trainset, eval_kwargs):
results_best[id(predictor)]["max"].append(max(scores))
results_best[id(predictor)]["average"].append(sum(scores) / len(scores))
results_best[id(predictor)]["min"].append(min(scores))
results_best[id(predictor)]["std"].append(np.std(scores))
results_best[id(predictor)]["std"].append(statistics.pstdev(scores))

candidates.sort(key=lambda x: x["score"], reverse=True)

Expand Down
4 changes: 2 additions & 2 deletions dspy/teleprompt/gepa/gepa.py
Original file line number Diff line number Diff line change
Expand Up @@ -443,9 +443,9 @@ def __init__(
def auto_budget(
self, num_preds, num_candidates, valset_size: int, minibatch_size: int = 35, full_eval_steps: int = 5
) -> int:
import numpy as np
import math

num_trials = int(max(2 * (num_preds * 2) * np.log2(num_candidates), 1.5 * num_candidates))
num_trials = int(max(2 * (num_preds * 2) * math.log2(num_candidates), 1.5 * num_candidates))
if num_trials < 0 or valset_size < 0 or minibatch_size < 0:
raise ValueError("num_trials, valset_size, and minibatch_size must be >= 0.")
if full_eval_steps < 1:
Expand Down
5 changes: 2 additions & 3 deletions dspy/teleprompt/infer_rules.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import logging
import math
import random

import numpy as np

import dspy
from dspy.evaluate.evaluate import Evaluate
from dspy.teleprompt import BootstrapFewShot
Expand Down Expand Up @@ -32,7 +31,7 @@ def compile(self, student, *, teacher=None, trainset, valset=None):
all_predictors = [p for p in original_program.predictors() if hasattr(p, "signature")]
instructions_list = [p.signature.instructions for p in all_predictors]

best_score = -np.inf
best_score = -math.inf
best_program = None

for candidate_idx in range(self.num_candidates):
Expand Down
6 changes: 2 additions & 4 deletions dspy/teleprompt/mipro_optimizer_v2.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import logging
import math
import random
from collections import defaultdict
from typing import TYPE_CHECKING, Any, Callable, Literal

import numpy as np

import dspy
from dspy.evaluate.evaluate import Evaluate
from dspy.propose import GroundedProposer
Expand Down Expand Up @@ -277,14 +276,13 @@ def compile(

def _set_random_seeds(self, seed):
self.rng = random.Random(seed)
np.random.seed(seed)

Comment thread
isaacbmiller marked this conversation as resolved.
def _set_num_trials_from_num_candidates(self, program, zeroshot_opt, num_candidates):
num_vars = len(program.predictors())
if not zeroshot_opt:
num_vars *= 2 # Account for few-shot examples + instruction variables
# Trials = MAX(c*M*log(N), c=2, 3/2*N)
num_trials = int(max(2 * num_vars * np.log2(num_candidates), 1.5 * num_candidates))
num_trials = int(max(2 * num_vars * math.log2(num_candidates), 1.5 * num_candidates))

return num_trials

Expand Down
4 changes: 2 additions & 2 deletions dspy/teleprompt/simba.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,10 @@
import random
from typing import Any, Callable

import numpy as np

import dspy
from dspy.teleprompt.simba_utils import append_a_demo, append_a_rule, prepare_models_for_resampling, wrap_program
from dspy.teleprompt.teleprompt import Teleprompter
from dspy.utils.lazy_import import require

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -103,6 +102,7 @@ def compile(
# Basic checks
assert len(trainset) >= self.bsize, f"Trainset too small: {len(trainset)} < {self.bsize}"

np = require("numpy")
# Initialize RNG
rng = random.Random(seed)
rng_np = np.random.default_rng(seed)
Expand Down
11 changes: 4 additions & 7 deletions dspy/teleprompt/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
import shutil
import sys

import numpy as np

try:
from IPython.core.magics.code import extract_symbols
except ImportError:
Expand Down Expand Up @@ -121,8 +119,8 @@ def get_program_with_highest_avg_score(param_score_dict, fully_evaled_param_comb
# Calculate the mean for each combination of categorical parameters, based on past trials
results = []
for key, values in param_score_dict.items():
scores = np.array([v[0] for v in values])
mean = np.average(scores)
scores = [v[0] for v in values]
mean = sum(scores) / len(scores)
program = values[0][1]
params = values[0][2]
results.append((key, mean, program, params))
Expand Down Expand Up @@ -284,9 +282,8 @@ def get_token_usage(model) -> tuple[int, int]:
input_tokens.append(_input_tokens)
output_tokens.append(_output_tokens)

total_input_tokens = int(np.sum(input_tokens))
total_output_tokens = int(np.sum(output_tokens))

total_input_tokens = sum(input_tokens)
total_output_tokens = sum(output_tokens)
return total_input_tokens, total_output_tokens


Expand Down
11 changes: 8 additions & 3 deletions dspy/utils/dummies.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
from __future__ import annotations

import random
from collections import defaultdict
from typing import Any

import numpy as np
from typing import TYPE_CHECKING, Any

from dspy.adapters.chat_adapter import FieldInfoWithName, field_header_pattern
from dspy.clients.base_lm import BaseLM
from dspy.dsp.utils.utils import dotdict
from dspy.signatures.field import OutputField
from dspy.utils.lazy_import import require

if TYPE_CHECKING:
import numpy as np


class DummyLM(BaseLM):
Expand Down Expand Up @@ -195,6 +199,7 @@ def _hash(self, gram):
return h % self.max_length

def __call__(self, texts: list[str]) -> np.ndarray:
np = require("numpy")
vecs = []
for text in texts:
grams = [text[i : i + self.n_gram] for i in range(len(text) - self.n_gram + 1)]
Expand Down
80 changes: 80 additions & 0 deletions dspy/utils/lazy_import.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Lazy-import helpers for optional dependencies.

Dspy ships in two flavors with different hard-dependency sets (`dspy` and
`dspy-runtime`). Optional deps must be importable lazily so that `import dspy`
succeeds even when they are absent, and call sites must raise a clear,
actionable ImportError when the dep really is needed.
"""

import functools
import importlib
import importlib.util
from typing import Any

_INSTALL_HINTS: dict[str, str] = {
"optuna": "optuna",
"mcp": "mcp",
"langchain_core": "langchain",
"weaviate": "weaviate",
"anthropic": "anthropic",
"numpy": "numpy",
}


@functools.cache
def is_available(module: str) -> bool:
"""Return True if ``module`` can be imported, without importing it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

single back-tick is preferred in our settings of mkdocs.


Uses ``importlib.util.find_spec`` so calling this does not execute the
module's top-level code. Safe for cheap branching ("if the optional dep
is installed, register the hook; otherwise skip").
"""
try:
return importlib.util.find_spec(module) is not None
except (ImportError, ValueError):
return False


def require(module: str, *, extra: str | None = None, feature: str | None = None) -> Any:
"""Import a module by dotted path; raise a friendly ImportError if missing.
Comment thread
isaacbmiller marked this conversation as resolved.
Outdated

Use at call sites where an optional dependency is needed to perform an action.

Args:
module: Dotted module path (e.g. ``"litellm"`` or ``"gepa.core.adapter"``).
The top-level segment is shown to the user.
extra: Name of the dspy extra that pulls in this dep. Defaults to the
entry in ``_INSTALL_HINTS`` for the top-level module, falling back
to the top-level module name.
feature: Short feature label included in the error (e.g. ``"dspy.LM"``).
Defaults to ``"this feature"``.

Returns:
The imported module.
"""
try:
return importlib.import_module(module)
except ImportError as e:
top = module.split(".", 1)[0]
feat = feature or "this feature"
ext = extra or _INSTALL_HINTS.get(top, top)
raise ImportError(
f"{top} is required to use {feat}. "
f"Install with `pip install dspy[{ext}]` or `pip install {top}`."
Comment thread
isaacbmiller marked this conversation as resolved.
Outdated
) from e


def optional(module: str, attr: str | None = None, default: Any = None) -> Any:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

optional is not used, can we wait that it's needed before making this util?

"""Try to import a module (and optionally one attribute). Return ``default`` if missing.

Use at module load time when a class needs to inherit from a base provided by
an optional dep: returning a sentinel (typically ``object``) lets the class be
defined even when the dep is absent. Gate actual use behind ``require()``.
"""
try:
mod = importlib.import_module(module)
except ImportError:
return default
if attr is None:
return mod
return getattr(mod, attr, default)
Loading