Troubleshooting¶
For people adding a new family of ambiguity sets, and the DRPP it needs, or running
experiments on top of ppsds: the traps, what they look like when they bite, and what to do.
This page is deliberately separate from the A new family and its DRPP guide so that the guide can stay a straight line from idea to figure. It complements rather than repeats Getting Started, which covers the pitfalls of using the shipped predictors; read that one first if you have not.
Each entry has the same three parts: the symptom you will actually see, the cause, and the fix. Symptoms are phrased the way they present, so the page is searchable by the text of an error message rather than by topic.
Installation and environment¶
Most installation problems are not specific to extending; Getting Started has the authoritative platform notes. The three below are the ones that show up while developing against a checkout.
conda env create aborts with CondaVerificationError ... appears to be corrupted¶
Symptom: building the environment from environment.yml fails with a message claiming a
package is corrupted. Retrying and clearing the cache change nothing.
Cause: the conda directory is on a case-insensitive filesystem. Packages contain paths that
differ only in case (ncurses alone has 25 such pairs under share/terminfo/), and on a
case-insensitive filesystem they overwrite each other. It is not a download problem, so no amount of
retrying helps.
Fix: put the conda directory on a case-sensitive filesystem. On macOS the default APFS is case-insensitive, and a Docker bind mount inherits the host's semantics; a named volume (ext4) does not. You can check a directory directly:
touch /path/to/check/P12-M /path/to/check/p12-m && ls /path/to/check/p12*
# one file listed -> case-insensitive (unsuitable); two -> case-sensitive
The cone program fails to build, or cvxpy will not import, on Linux¶
Symptom: installing cvxpy from source fails, or importing it raises a compiler error.
Cause: cvxpy needs a C toolchain when pip finds no prebuilt wheel for your platform.
Fix: install gcc (and the usual build essentials), or use a Python version with published
wheels. cvxpy is a hard dependency of the package, but it is imported lazily, only when a
cone program is actually solved, so the nominal predictor and the environment layer work without a
working solver.
Plots do not appear, or matplotlib aborts on a headless machine¶
Symptom: a script that calls plt.show() does nothing, or matplotlib raises about a missing
display.
Cause: there is no display.
Fix: set a non-interactive backend: MPLBACKEND=Agg python your_script.py. The test suite does
this, and it is what CI does. matplotlib is an optional dependency: install it with
pip install "ppsds[experiments]", and code that uses it belongs in the periphery, not in a core
module.
Numbers and dtypes¶
Two of these are version-dependent and two are conventions. All four produce a wrong number rather than a crash.
The cone program raises The 'minimize' objective must be real valued¶
Symptom: eigvec_fixed_minmax fails with that message, on a real symmetric input covariance.
The traceback points nowhere near the real cause.
Cause: np.linalg.eig does not return a consistent dtype across numpy versions. For the
same real symmetric matrix it returns float64 on some versions and complex128 on others (with
imaginary parts exactly zero and identical real parts). A complex array reaching the cone program
makes it complain about the objective.
Fix: never call np.linalg.eig directly. Use
ppsds.numerics.linalg.eig_real,
which takes the real part of both the eigenvalues and the eigenvectors. That is lossless for a
symmetric matrix, which is the only case here. Do not reach for it as a general-purpose
eig wrapper.
A tiny change flips which direction the adversary attacks¶
Symptom: you nudge a tolerance, or an unrelated numerical change lands, and the reported
worst-direction index changes. The scores move by about 1e-9, so nothing looks broken.
Cause: the cone program keeps the best of several candidate directions. When two candidates are
within about 1e-14 of each other, the choice between them is decided by floating-point noise. The
solver's own parameters (max_iters, eps) participate in that.
Fix: treat the solver parameters as load-bearing, not as tuning knobs: changing them changes a discrete output. If you must change them, expect the discrete index to move and justify it with the score magnitudes. Do not assert on the index in a test unless you mean it; the shipped suite has exactly one such assertion, and it is the most likely assertion to go red one day.
The predictive covariance is symmetric only up to rounding¶
Symptom: a test asserting exact symmetry of a predictive covariance fails by about
2.2e-16, and passes on your machine but fails in CI.
Cause: the covariance is rebuilt as v @ diag(lamb) @ v.T. In exact arithmetic that is
symmetric; in floating point the two off-diagonal entries accumulate in different orders, so they can
differ in the last bit. Different BLAS builds round differently, which is why the failure moves
between machines.
Fix: compare with a small relative tolerance (about 1e-12 times the magnitude of the matrix),
not for equality. If you see a difference far larger than machine epsilon, that is a real bug, not
this.
Numbers come out off by a square, or by d/2¶
Symptom: a score or a radius is wrong by a factor, a square, or a constant offset, with no error anywhere.
Cause: two easy confusions. First, radii that are squared and radii that are not: the
shipped family mixes them on purpose, and Getting
Started tabulates which is which. Second, an expected score
bound carries a +d term from the Gaussian differential entropy, while a logpdf evaluated at a
point does not; mixing the two shifts everything by d/2.
Fix: for the shipped family, take the table in Getting Started as the reference. For your own family, the conventions are yours to define, and the trap is not writing them down: state, in the docstring of the class, which quantity each parameter bounds, and whether it is squared. A radius whose meaning is only in your head will be read the other way by the next person.
You assumed the robust covariance dominates the inflated one, and it does not¶
Symptom: a test or a claim that the robust predictor's covariance is at least the noise-level one in every direction fails on some inputs.
Cause: the cone constraint allows coordinates whose predictive eigenvalue falls below the scaled nominal one; the robustification is not a componentwise inflation. Attempts to assert dominance pass or fail depending on the draw.
Fix: if you need a dominance statement, state the one that is actually true for your family and test it on a fixed seed. For the shipped family the meaningful statements are the two reference bounds, and the inflation along the worst direction. See Preliminaries.
Reproducibility¶
Results drift when you add an unrelated random call¶
Symptom: after a change that should not affect numbers, every sampled trajectory is different.
Cause: the samplers draw from the global numpy random state. Any extra draw anywhere
before them shifts the whole consumption order, so all downstream samples change. This is a
deliberate design: reproducibility is the caller's, not the library's.
Fix: seed at the call site, once, immediately before sampling (np.random.seed(...)). Do not
insert a random call into a code path that others' existing results depend on. If you are adding a
mode that samples, follow the shipped random mode: it seeds the global state from the environment's
own generator and then restores it, so stepping an episode does not pollute the caller's state.
Multiprocessing¶
cannot pickle 'function' object, or the runner refuses to start¶
Symptom: worker processes die with a pickling error, or the batch runner raises a RuntimeError
mentioning fork and "not picklable".
Cause: ambiguity sets routinely hold lambdas (a drift callable, a radius callable). Lambdas
cannot be pickled, and worker processes receive their work by pickling unless the start method is
fork, which shares memory instead.
Fix: use fork. The runner asserts the start method and fails loudly rather than degrading
silently, because the silent version of this failure is worse: with spawn (the default on macOS and
Windows) it would error in a way that looks like a bug in your family. Two consequences when you
write your own driver: call the runner's configure() before creating any process pool, and keep
worker functions at module level so the pool can reference them.
The cache¶
A figure claims more trajectories than were ever computed¶
Symptom: the plotted result is smoother than it should be, or a re-run with a larger -t
finishes surprisingly fast.
Cause: this was a real bug, and it is worth knowing the shape of it: the old cache skipped work when the output file merely existed, and its key did not encode the trajectory count. A directory holding 5 trajectories was reused for a request of 100.
Fix: the cache is now validating: a step is reused only if its manifest still matches. Know what invalidates it, because the answer surprises people:
| invalidates | does not invalidate |
|---|---|
| the parameters in the run's snapshot | the plotting code (excluded by module prefix) |
the bytes of any loaded ppsds.* module |
the working directory you launch from |
| a dependency file's sha256 | |
| array shapes that no longer match |
The source fingerprint is computed byte for byte over the modules that are loaded. So renaming a module, moving a file, or editing a docstring or a warning message invalidates the whole cache, including the warning text, which is API. Expect a recompute after any such edit; it is the intended price of the guarantee, not a bug.
The cache never hits, or a manifest looks stale immediately¶
Symptom: every run recomputes, even though nothing changed.
Cause: the fingerprint is taken over sys.modules, so it only sees what has already been
imported. Building the cache context before all imports have finished produces a fingerprint of an
incomplete module set.
Fix: construct the cache context at the entry point, after imports. And remember that the
results directory is not under version control: the cached pickles are the only copy, so a forced
recompute (-f) is a real cost, not a refresh. See
Examples for the runner's own account of this.
The core and periphery boundary¶
import ppsds got slower, or CI's core-only job fails¶
Symptom: a plain pip install ppsds starts pulling plotting libraries; or the core-only
continuous-integration job fails while everything passes locally.
Cause: a core module imported a periphery module. The core is ambiguity, dynamics,
probabilistic_predictor, environment, numerics, scoring_rule, control_policy, and it may
depend only on numpy, scipy, cvxpy and gymnasium. The periphery (experiment, visualization)
adds matplotlib, pandas, seaborn and tqdm, and is installed by an extra.
Fix: keep the import out of the core. This one is checked three ways, so it will not stay broken: a static test, a test that loads each core entry point in a clean subprocess, and a CI job that installs the package without the extra, which is the only way to prove the boundary rather than merely assert it. If you add a core module, add it to that test's list of entry points.
Nobody notices that the solver import crept to module level¶
Symptom: import ppsds becomes noticeably slower, or importing it fails where no solver is
installed.
Cause: cvxpy is a hard dependency, but it is imported lazily, inside the function that
solves the cone program. That is why the nominal predictor and the environment layer cost nothing
extra.
Fix: keep it lazy. If your family needs a solver, import it inside the function that uses it.
Shapes and parameter formats¶
An error deep inside predict() whose cause is invisible¶
Symptom: predict() raises something like Array 'mean' must be a vector of length N², and
the code that produced the bad state is far away.
Cause: a mis-shaped input was silently broadcast. Passing a control of shape (1, 1)
turned a matrix-vector product into a (d, 1) array, and adding a (d,) bias broadcast the whole
thing to (d, d). NumPy is happy to do this; the failure surfaces much later, in a different shape.
Fix: validate at the boundary. The predictors do this with a helper that checks rank and length
and raises naming the offending argument. Do the same in your own update(): an error raised where
the mistake happened is worth ten raised downstream.
ValueError naming a key you thought was fine¶
Symptom: Env.step() raises, naming the offending key and the accepted ones.
Cause: the environment validates the parameter key set at the top of step(), against the set's
param_keys. Two families use different key sets, and the validation exists precisely because an
earlier version guessed the format by testing for a key's presence, which swallowed exactly this
mistake.
Fix: make sure your mode returns only keys your set declares. The common instance: the extra
key that worst_case_params() returns to report the worst direction. It is diagnostic, not a
parameter, and it must be removed before the parameters are handed to step().
An array changed under you¶
Symptom: a nominal parameter drifts a little after every sample; results differ between two runs that should be identical.
Cause: the sets hold arrays by reference (np.asarray does not copy), so mutating
something you obtained from a set mutates the set.
Fix: copy before you write (B = self.B_bar.copy()), never hand a module-level array to a set,
and give every factory a fresh one. A related trap on the consumer side: arrays passed as
sds_params follow the same rule.
Does your set contain its own generator?¶
The invariant: every sample your generator produces must be a member of the set you declared. If it is not, your ground truth lies outside your own declaration, and the guarantee is vacuous: in simulation it shows up as a bound that appears to fail, which looks like an algorithm bug and is actually a declaration bug. Preliminaries states this as a design invariant; here is what it cost when it was violated.
The drift sampler put most of its samples outside the declared ball¶
Symptom: none, at run time. It surfaced only when someone measured it.
Cause: the sampler drew the drift perturbation from a Gaussian whose standard deviation was set
by a 3σ heuristic, divided by three and applied per component. That ignores the dimension: the
expected squared norm grows with d, so at d = 12 the draw was already past the bound on average.
Fix: the corrected sampler draws uniformly inside the ball of the declared radius. Measured on the same test, the fraction of draws outside the set went from 70.1% to zero. If your radius is a norm bound, sample inside the ball; do not sample per component and hope.
The noise-mean box was twice as wide as its own declaration¶
Symptom: again none until measured.
Cause: the noise mean is declared with a squared radius: the constraint is on the norm,
|mu - mu_bar| <= sqrt(gamma). Sampling each component from a box of half-width sqrt(gamma)
instead of sqrt(gamma / d) makes each component's box sqrt(d) times too wide, and the
worst-case squared norm d times the declaration.
Fix: divide the half-width by sqrt(d). More generally: when a radius bounds a norm, every
sampler for it needs the dimension in it somewhere, and the place it goes is easy to get wrong.
The drift bound must be the sum, not the combined norm¶
Symptom: about 1% of samples fall outside the declared set, with everything else looking right.
Cause: when a perturbation splits into a state part and a control part, the tight bound on the sum is the sum of the two norms (the triangle inequality is tight here). A single combined-norm expression is always smaller, so it excludes samples the generator actually produces.
Fix: use the sum. This is worth internalising as the general pattern: the bound must be the one your sampler can saturate, not the tightest-looking expression you can write down.
An aliasing bug that made the nominal model walk away¶
Symptom: the set's nominal parameters drift permanently, a little per sample.
Cause: a sampler assigned B = self.B_bar instead of a copy and then wrote into B. Since the
trajectory sampler calls sample() once per period, the nominal value took a random walk, silently,
and permanently.
Fix: copy on the way in. There is a test for exactly this: sampling must not change the set.
When the paper and the code disagree, sample from it and ask which is self-consistent
That question has resolved every instance of this class of bug so far, in both directions: the paper's closed form was wrong once, and the code's sampler was wrong twice. Neither "the paper is authoritative" nor "the code is authoritative" is the right reflex. The useful question is "which of these contains its own generator?"
Predictor state and reset¶
Reusing a predictor for a second trajectory gives numbers from the first¶
Symptom: the second trajectory's first row carries the previous episode's last prediction instead of an empty placeholder.
Cause: the full-trajectory helper rewrites f_bar / mu_bar / Sigma_bar in place, and
derived quantities (eigenvalues, eigenvectors) follow them. Clearing only the prediction outputs
leaves the first trajectory's parameters in place for the second.
Fix: reset() must restore the nominal quantities from a saved copy and recompute what is
derived from them, not just null the outputs. The interface's own docstring lists which quantities
this means. The shipped helper does it in a finally, so an exception mid-trajectory does not leave
the instance poisoned either.
An interface that raises at construction time when you inherit it¶
Symptom: a class that satisfies an interface by inspection fails with AttributeError the
moment it is instantiated.
Cause: the interface declared its attributes as @property, which is a data descriptor,
while implementations assigned them as plain instance attributes. The two are incompatible. Worse, the
interface was registered virtually, so isinstance() returned True while nothing was enforced,
the incompatibility was hidden rather than surfaced.
Fix: this is why the predictor interface is a typing.Protocol: attributes are declared by
annotation (which a plain instance attribute satisfies), methods by ..., there is zero runtime
overhead, and no inheritance is involved or wanted. When you define an interface, make sure the
implementations could actually satisfy it, because an interface you cannot implement is worse than none.
Figures¶
RuntimeWarning about a trajectory index being out of range¶
Symptom: running the batch runner with a small trajectory count prints a warning like:
RuntimeWarning: trajectory index in plot_traj 2 out of range (available 0..1); using 1
instead. Note that the figure content changes accordingly; to show a specific trajectory,
raise -t or use a smaller index.
Cause: which trajectories to plot is fixed in the model table, so a small -t cannot satisfy
it. This used to raise IndexError after the expensive computation had finished, throwing away
a completed run.
Fix: nothing breaks; the index is clamped to the last available trajectory. The warning is not
suppressed because clamping changes what the figure shows; that is the part worth reading. Raise
-t, or lower the index in the model table.
A run "passed" but the figure is wrong¶
Symptom: the command exits 0 and the figure does not say what you think.
Cause: the exit code cannot see a clamped index, and it cannot see a number that moved either.
Fix: treat the data, not the exit status, as the acceptance criterion. That is exactly the choice the A/B comparison tool makes: it compares the pickles element by element rather than trusting whether a run completed.
Known rough edges in this release¶
Honest limits, as of 0.0.3-alpha (0.0.3a0), so you do not spend an afternoon rediscovering
them.
The full-trajectory prediction helper is deprecated, and its replacement is incomplete¶
Symptom: a FutureWarning telling you to migrate to the environment layer.
Cause: the helper runs a whole trajectory in one call. It is deprecated in favour of stepping an
Env, which is what the examples and the batch runner do.
Fix: use the environment layer for anything new. Note the honest caveat: the helper's linear time-varying path has no equivalent in the environment layer, so if that is the path you need, the migration target does not exist yet. Say so rather than silently relying on a deprecated call.
The shipped family cannot express general uncertainty in A and B¶
Symptom: none; this is a limit of what the shipped family can express, and a reason you may be writing a new one.
Cause: the shipped linear parameterization perturbs only the superdiagonal of A and a single
entry of B. That is the structure of the paper's own example, not a general description of
uncertainty in A and B, and it cannot express it.
Fix: Preliminaries states this limit precisely; read it before concluding that the shipped family is a general-purpose conic-moment implementation. Extending it is exactly what the A new family and its DRPP guide is for. A smaller edge of the same kind: one assertion in the golden fixture pins the discrete worst-direction index, which makes it the most likely test to go red on a different numerical stack.
Next¶
- Do the work this page is troubleshooting: the Developer Guide
- Understand what you are extending: Preliminaries
- See a finished one: Examples — each example prints the numbers it talks about
- Check which part you are touching: the map