Skip to content

pyspi 3.0.0 - #103

Open
willedibam wants to merge 123 commits into
DynamicsAndNeuralSystems:mainfrom
willedibam:v3
Open

willedibam wants to merge 123 commits into
DynamicsAndNeuralSystems:mainfrom
willedibam:v3

Conversation

@willedibam

Copy link
Copy Markdown

Major release. Brings main in line with willedibam/pyspi v3 branch. Breaking changes — see "Migrating from 2.x" in CHANGELOG.md, which is the full record; below is a summary of changes.

Headline changes

  • Java/JIDT removed. Every information-theoretic estimator is pure NumPy, validated against JIDT 1.6.1 before the dependency was dropped. No JVM or jpype.
  • cdt and torch removed. The four pairwise causal functions pyspi used are transcribed on NumPy/SciPy/scikit-learn, bit-identical to cdt 0.6 on the reference pairs. Installed size roughly halves.
  • Correctness fixes to stale caches after data mutation, checkpoint/resume binding, spectral cache keys, and several estimators (directed coherence, cointegration, group delay, cross-correlation, KSG input checks).
  • Packaging is pyproject.toml only; setup.py and requirements.txt are gone.

Breaking API changes

  • Calculator(subset=..., configfile=...) → Calculator(config=...) (bundled name or path to a YAML).
  • normalise= → zscore= on Calculator and Data.
  • Configs moved to pyspi/configs/ and renamed (full, fast, sonnet, fabfour, benchmarked_p*).
  • Results are saved with Calculator.save() / pyspi.load_table() as .npz; pickle and parquet output are dropped.

Bundled configs

Selected by name via Calculator(config=...); the default is full.

Config SPIs Use
full 322 Everything
benchmarked_p99 318 Only the worst cost outliers removed
benchmarked_p95 305 Coverage/cost trade-off
benchmarked_p90 289 Recommended for large batches and main driver
benchmarked_p80 261 Cost-constrained sweeps
fast 213 Fast subset
sonnet 14 One representative SPI per module
fabfour 4 Smoke tests

The benchmarked_p<N> sets keep the fastest N% of SPIs by measured cost (M=16, T=800, single job). They existed in 2.x only as files; they are now reachable by name.

Numerical changes

  • full goes from 328 to 322 SPIs (7 removed, 1 added), each with a stated reason.
  • All information-theoretic SPIs now report nats, so kernel and symbolic values change by a factor of ln 2.
  • Other SPI families whose values differ from 2.0.1 (dcoh_*, coint_aeg_*, psi_wavelet_*, ccm_E-None_*, gd_*, xcorr_*, kraskov, and others) are tabulated with causes under "SPI set changes".

Dependencies

Python >= 3.10, numpy >= 2.0, pandas >= 2.1, pyEDM >= 2.5, spectral-connectivity >= 1.1,< 3. No setuptools pin.

Testing

  • New analytic tests (closed-form Gaussian MI/entropy/TE), directionality tests, cache-sharing and run-identity tests.
  • The baseline drift suite now fails on violations instead of only reporting them; baselines are regenerated as .npz.
  • New CI job builds the wheel and exercises it from a clean environment.

Relation to open PRs and issues

Reviewing

  • The diff is large at about 100 files, +26k / −3.4k.
  • Most added code lines are peripheries: SPI configs (pyspi/configs/, ~6.5k); testing pipelines (tests/, ~7.3k); env dependencies (uv.lock, ~3.3k); and benchmarking suites (bench/, ~2.1k).
  • Suggested order: CHANGELOG.md, then pyspi/calculator.py and pyspi/data.py, then pyspi/statistics/, then tests/.

After merge

  • Tag v3.0.0 and publish the release.
  • Update the gitbook docs.

willedibam and others added 30 commits March 18, 2026 09:02
…s, parallel compute, new SPIs (LaggedCorrelation, CrossPairwiseDistance)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- basic.py: fix SpearmanR/KendallTau label duplication (self.labels += [x]
  mutated shared class attribute; changed to self.labels = self.labels + [x])
- utils.py: stub is_jpype_jvm_available() and check_optional_deps() to always
  return False/empty — JIDT replaced with pure-numpy equivalents, no JVM needed
- infotheory.py: implement AIS-based auto-embedding for TransferEntropy
  (gaussian and kraskov estimators); replaces JIDT MAX_CORR_AIS path that was
  returning NaN with 'unhandled estimator' warning
- calculator.py: related fixes for numpy-only operation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CrossPairwiseDistance._dist is RMSE-by-construction (sqrt(mean((a-b)**2))),
so the identifier now reflects that explicitly: xpdist_<metric>_tau-<K>_<stat>_rmse.
Mirrors the _rmse suffix used by PairwiseDistance and DynamicTimeWarping
when normalise=True. No behavior change.
JIDT/Java replacements have been numpy-pure since 7dca294 but the legacy
gates remained:

- calculator.compute() excluded kernel/symbolic/auto-embed TE from the
  fork worker pool under a "JVM does not survive fork()" rationale that
  no longer applies; reunify the parallel path.
- config YAMLs declared dependencies: [java] on eight infotheory SPI
  classes, causing Calculator to print spurious "install java" warnings
  and skip 24 SPIs in fast_config.yaml.
- utils.is_jpype_jvm_available() is a dead stub; fold check_optional_deps
  down to an empty dict and drop the "Checking optional dependencies..."
  print.
- __init__.py's OMP_NUM_THREADS=1 comment referenced JPype; rewrite to
  reflect the real reason (fork-worker BLAS oversubscription).

tests/test_SPIs.py previously failed collection when any current SPI key
(e.g. dtw_constraint-sakoe-chiba_radius-auto) lacked a frozen-baseline
entry. Compute the intersection of current vs baseline keys, skip the
symmetric differences with a logged summary, and parametrize only the
shared set.
Replace setup.py + requirements.txt with a self-contained pyproject.toml.
Bump numpy>=2.0, scipy>=1.11, pandas>=2.0, scikit-learn>=1.3, mne>=1.6,
pyEDM>=1.15. Drop sktime (replace with lazy aeon import for .ts files,
which is the only place it was used). Drop future (unused).

mne migration: port wavelet.py and misc.py from the removed
mne.connectivity submodule to the mne-connectivity package. The new
API returns Connectivity objects rather than tuples; call
.get_data(output='dense') and .freqs on them. PSI now calls
phase_slope_index per-band because the new API aggregates internally.

numpy 2 fixes:
- infotheory: use stdlib math.factorial (np.math was removed).
- causal: np.max instead of Python max on 2-D slice (int() on 1-D
  arrays was permissive in numpy 1.x).

pyEDM 2.5 fix:
- causal: EmbedDimension now requires a target arg even for self-
  simplex; pass columns as target.

pandas 3 fix:
- basic: df.corr().values is read-only; copy before fill_diagonal.
- calculator: defensively copy SPI returns before filling diagonal
  so any SPI returning a view doesn't break the fork worker.

cdt still imports torch.utils.data at module load without declaring
torch as a Requires-Dist, so torch stays an explicit dependency.

pyEDM 2.5 no longer needs pkg_resources at runtime, so the
setuptools<80 pin is no longer required.
Replaces the single-dataset z-score correctness test with a 3-dataset
ATOL/RTOL tolerance test (CML7 + new VAR1 + new Kuramoto). Baselines
come from upstream pyspi 2.0.1 run in an ephemeral uv venv (10 trials,
seed 42, element-wise mean per SPI). Drift is logged to a session-end
summary table via the spi_warning_logger fixture rather than failing
the suite — version-bump noise stays visible without blocking CI.

- tests/generate_benchmark_datasets.py: fixed-seed VAR(1) and Kuramoto
  generators (M=7, T=100) matching cml7.npy dimensions.
- pyspi/data/{var1_7,kuramoto_7}.npy: frozen synthetic datasets.
- tests/{VAR1,Kuramoto}_benchmark_tables.pkl: upstream-computed
  per-SPI mean/std across 10 trials.
- tests/test_SPIs.py: parametrized over (dataset, SPI), passes when
  abs_diff <= ATOL (1e-6) OR abs_diff <= RTOL * |ref| (1e-2). SPIs
  absent on either side are skipped with a one-line per-dataset report.
- tests/conftest.py: logger now records (max_abs, max_rel) instead of
  a single z-score; summary table widened to 90 cols.

842 cases pass. Most drift appears on Kuramoto spectral/wavelet SPIs
(coherent phase data saturates them near 1.0 so any BLAS/RNG noise
crosses the 1% threshold) and on ill-conditioned covariance SPIs
(prec*, coint_johansen*) where baseline and fork disagree by large
factors that are numerical not algorithmic.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
CrossPairwiseDistance is a bespoke SPI written for the CML case study.
The previous per-lag cost was a plain RMSE over the untrimmed overlap,
which did not line up with how DynamicTimeWarping(normalise=True)
scores the same pair. The new cost realises one specific monotone
warping path — pair x[k+s] with y[k] over the overlap, then stutter
the dropped boundary samples against the opposite series' corner —
and normalises by sqrt(T). Because the result is a valid DTW path
cost and DTW minimises over all paths, the identity
dtw_rmse <= xpdist now holds by construction, which is the property
the case study relies on.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two estimator-correctness fixes to infotheory.py:

1. Gaussian entropy: introduce _gaussian_log_det(cov, ridge_rel=1e-8) with
   epsilon = ridge_rel * mean(diag(cov)) and NaN fallback on persistent
   singularity. Refactor GaussianEntropyCalculator and the module-level
   _gaussian_entropy_from_data helper to use it. Fixes xme_gaussian_k10
   overflow on Kuramoto (was -inf from slogdet sign<=0 -> 1.8e+308 via
   np.nan_to_num in regression test) and reduces 30+ unit drift on
   cce_gaussian and di_gaussian. Rationale matches JIDT's
   NOISE_LEVEL_TO_ADD=1e-8: cov of x+noise equals Sigma + sigma**2 * I,
   so ridging Sigma directly is equivalent in expectation and numerically
   stabler than perturbing samples.

2. KernelTECalculator: DYN_CORR_EXCL was stored via setProperty but never
   consumed. The compute now queries kd-tree index lists and filters
   neighbours by |j - i| > dce when dce > 0, matching JIDT's 2*dce+1-point
   exclusion window. Samples whose counts go to zero after filtering are
   dropped from the mean; returns NaN if all samples degenerate. Fast path
   (dce None or <= 0) is byte-identical to the prior implementation.

839/839 regression tests pass. Kuramoto:xme_gaussian_k10 drift drops from
1.8e+308 to 5.9; cce_gaussian 30+ -> 24.9; di_gaussian 30+ -> 17.6.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- config.yaml: add LaggedCorrelation (18 variants, pearson/spearman/kendall
  x tau/max_tau x squared) and CrossPairwiseDistance (2 variants), which
  exist as code in pyspi/statistics/{basic,distance}.py but had no YAML
  entries in the fork. The fork's config.yaml is now the global SPI
  superset that downstream case configs subset from.
- config.yaml + fast_config.yaml: comment out
  InformationGeometricConditionalIndependence. cdt's IGCI.predict_proba
  takes no kwargs, so the 'kernel' variant has no computational backend
  in the fork (and arguably upstream too).

Upstream-inherited commented-out blocks are retained:
TransferEntropy.kernel+MAX_CORR_AIS (JIDT AIS overload bug) and
PartialCoherence (no backing class). Those are deliberate upstream
exclusions, not fork choices.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Python 3.12 venvs no longer bundle setuptools by default (PEP 632), and
setuptools>=81 dropped pkg_resources from the default install. pyEDM's
LoadData.py still does `import pkg_resources` at module load, so without
this pin a fresh venv fails test collection with ModuleNotFoundError.
Mirrors the equivalent pin in mts-spi-study-cluster.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- pyspi/benchmarked_config.yaml: 292-SPI set (~5s-per-SPI threshold at M=8 T=3200
  and M=16 T=800). Mirrors configs/pyspi-v2/benchmarked_config.yaml in
  mts-spi-study-cluster; kept in-tree here as the fork-local reference.
- uv.lock: pin resolved deps so downstream repos (incl. Gadi venv rebuilds)
  reproduce the exact environment.
Calculator.compute() gains n_jobs, checkpoint_dir, resume, mp_context, and
progress kwargs. Defaults reproduce prior serial behaviour bit-for-bit;
PYSPI_N_JOBS env var still honoured as a fallback. Drops the fork-only
_PARALLEL_CALC global path in favour of a portable spawn-default executor.

- pyspi/_parallel.py: ProcessPoolExecutor + multiprocessing.shared_memory
  for the dataset; SPIs bucketed by _cache_namespace so a within-class
  cache (covariance, spectral_mv/bv, barycenter, coint) is reused across
  every variant in the bucket; per-SPI failures yield NaN matrices; atomic
  per-SPI .npy checkpoints enable transparent resume.
- pyspi/__main__.py: thin CLI (python -m pyspi compute --data ... --config
  ... --n-jobs ... --checkpoint-dir ...).
- pyspi/statistics/*: six _cache_namespace tags on the relevant SPI bases.
- tests/test_parallel.py: parity at n_jobs=2,3; checkpoint resume; failure
  isolation; CLI smoke.

All 47 test_calc.py and 846 test_SPIs.py regression tests against the
frozen benchmark tables (CML7, Kuramoto, VAR1) pass unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ness

Calculator and Data both gain verbose= (default True) so library users can
silence the per-SPI init dump, the dataset detrend/normalise banner, and
the compute() completion summary. Dependency warnings still print
unconditionally — they affect correctness, not noise.

YAML parsing extracted into pyspi.calculator.load_spis_from_yaml(), a
plain function reused by Calculator and the parallel worker initializer
(workers no longer instantiate a throwaway Calculator with redirected
stdout — direct loader call).

Data.__init__ now initialises _data/n_processes/etc. explicitly so the
empty-instance state used by add_process() is no longer load-bearing on
hasattr() checks.

Test suite layout:
  tests/test_calc.py       -> tests/test_calculator.py
  tests/test_SPIs.py       -> tests/test_regression.py  (marked slow)
  tests/smoke_test.py      -> tests/test_smoke.py       (now auto-collected;
                                                         8 tests, was 0)
pyproject.toml registers the 'slow' marker and defaults pytest to
-m 'not slow', so the 12-minute regression sweep is opt-in via -m slow
while every-commit pytest stays at ~40s.

bench/bench_compute.py is the non-notebook replacement for the sister-repo
walltime sweep. Outputs tidy CSV + JSON metadata. Two intended workflows
documented in the module docstring: amortized-config re-cutting (n_jobs=1)
and parallel-speedup characterisation (n_jobs swept).

Net: 71/71 fast tests pass (~38s). Slow regression suite intact (run with
'pytest -m slow').

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The CML7/VAR1/Kuramoto baseline tables are pandas DataFrames + numpy
arrays only — no closures or lambdas — so stdlib pickle suffices and
dill becomes one less optional dep.

Verified by loading all three .pkl files via pickle.load() and by
collecting all 839 regression tests successfully.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Logging:
- New pyspi/_logging.py: a 'pyspi' logger with a colorama-tinted
  ColorFormatter. __init__.py attaches a NullHandler (library default:
  silent unless the app configures a handler).
- Calculator(verbose=) maps to a process-global pyspi logger level:
  True -> INFO (config load, per-SPI init, compute summary), False ->
  WARNING (problems only). Dependency-exclusion notices now log at
  WARNING so they surface regardless of verbose.
- All informational print()/Fore calls in calculator.py and data.py
  routed through the logger. SPI-failure reporting stays on warnings.warn
  (unchanged semantics; tests assert pytest.warns).
- CLI gains --quiet.

Per-SPI progress:
- Parallel runs now tick the tqdm bar once per SPI, not once per cache
  bucket. Workers post (key, failed) to a manager queue after each SPI;
  the main loop drains it for progress while still harvesting results
  (and dead-worker detection) from the futures. A stuck SPI is now
  visible as a stalled bar.

Data.__init__ drops the short-lived verbose kwarg added earlier this
branch — logging supersedes it.

71/71 fast tests pass; 839 slow regression tests unaffected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
mp_context default is now platform-aware: fork on Linux, spawn on
macOS/Windows. Measured head-to-head (252-SPI benchmarked90 config,
M=16 T=800): fork reaches 2.50x at n_jobs=4 / 3.65x at n_jobs=8, vs
spawn's 1.60x / 1.95x. spawn re-imports the heavy pyspi stack and
re-instantiates every SPI per worker (~2.5s tax); fork inherits that
state via copy-on-write.

Bug fix: run_parallel created the SharedMemory block and Manager
*outside* the try, so a failure constructing either leaked the shared
memory. Both are now created inside the try with None-guarded cleanup.

bench/bench_compute.py upgraded to the canonical benchmark suite:
- (M, T, n_jobs) grid with presets (headline/scaling/parallel/amortized)
- incremental JSON output, --resume, --array-index for PBS arrays
- environment block (pyspi git sha, dep versions + fingerprint, platform)
- per-cell wall time, per-SPI timings, peak RSS, failed-SPI count
- prints a speedup-vs-n_jobs summary

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
run_benchmark.pbs runs bench_compute.py on Gadi (parametrised via -v:
PRESET, CONFIG, REPEATS, PYSPI_DIR, VENV). README documents the presets,
output schema, and the n_jobs / amortized-config invariance.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
cdt (causal discovery toolbox, transitive dep) autosets SETTINGS.NJOBS to
cpu_count() at import. Inside an n_jobs worker pool that nests
process x thread parallelism and oversubscribes. _worker_init now pins
cdt.SETTINGS.NJOBS=1 after the SPI modules import.

Note: this fixes cdt-based causal SPIs but NOT ConvergentCrossMapping,
whose internal threading is pyEDM-side. CCM-containing configs (the full
config.yaml) should be benchmarked with n_jobs=1; the benchmarked_* configs
exclude CCM and parallelise cleanly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- _parallel.py: pin BLAS/OpenMP/cdt/torch in workers; _pin_blas_env sets
  thread env vars before spawn so workers start single-threaded. causal.py
  gates pyEDM parallelism (parallel/numProcess) on PYSPI_PIN_BACKENDS.
- Tag ConvergentCrossMapping with _cache_namespace so its variants share one
  worker and cache instead of recomputing.
- bench/cut_config.py: cut benchmarked<N>[_amortized] configs from a
  bench_compute JSON (amortized or raw cost) — replaces the notebook step.
- bench/run_benchmark.pbs + run_benchmark_physics.pbs: M/T grid override and
  PBS-array (--array-index) support for config-cutting runs at real data sizes.
- test_parallel.py: cover CCM/pinning, parametrise fork+spawn.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
USYD Physics has no walltime cap on the user's queue; size the PBS defaults
to whole-node + a week so heavy bench cells (full config at large M,T) fit
in one sequential job without overrides.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
bench_compute.py: write one JSON per (M, T, n_jobs) cell named
<label>_M<M>_T<T>_n<n>.json. M/T/n_jobs/environment now live at the top
level of each file (self-contained); --output-dir + --label replace
the old single --output path. --resume skips per-cell files.

cut_config.py: reads one per-cell JSON directly; --m/--t cell-picker
removed (file IS the cell).

run_benchmark[_physics].pbs (generic, env-var driven): rewritten to use
--output-dir + --label and to redirect stdout/stderr to bench/logs/.

bench/physics/run_bench_{light,m32,m64}.pbs: dedicated submission scripts
for the M={4,8,16}/{32}/{64} x T={200,400,800,1600,3200} grid. Each is a
single qsub away.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add MXX size-applicability tags to every SPI in the bundled configs
(plus the auxiliary spi_labels.csv reference table). Calculator now
splits per-config labels off the constructor kwargs and merges them
into the SPI's label list (handling Mxx overrides cleanly), so that
downstream consumers can filter SPIs by intended M-range.
- bench_compute: psutil per-cell RSS (was process high-watermark
  accumulating across cells); failed_spis list; per-SPI category/labels
- cut_config: handle per-config labels via _split_config_params;
  preserve dropped variants/classes as commented blocks by default
- analyse_cells: anchor-stability Jaccard, cumulative-cost elbow,
  per-SPI log-linear scaling fits, extrapolation to a target cell
- forecast_cell: predict cell wall-time at (M, T) from scaling fits
- backfill_metadata: retrofit category/labels into older cell JSONs
- physics PBS scripts: write into bench/results/cells/ to match the
  new layout (cells/ for per-cell JSONs, analysis/ for derived data)
Cluster sweep on the USYD Physics queue, full config.yaml at n_jobs=1:
M=4,8 across T={200,400,800,1600,3200}; M=16,32 across T={200,400,800,1600};
plus M=64 at T={200,400}. Backfilled with per-SPI category/labels at
current pyspi sha.

Analysis (bench/results/analysis/) holds the cross-cell artefacts that
feed the cut decisions: long_costs.csv, scaling fits, Jaccard matrices
at p{80,90,95,99}, cumulative-cost plots, kept-set drift heatmaps,
extrapolations to (M=64, T=3200), and a human-readable report.md.

Scaling-fit validation against the 2 unseen M=64 cells: median |log
error| ~14% per SPI; total cell-wall predictions within 13% of
measured. The (M=64, T=3200) extrapolations should be good to ~25-30%.
Re-cut from physics_config_M16_T800_n1.json (pyspi badfb4e) via
bench.cut_config --mode amortized. Anchored at M=16,T=800 because
kept-set Jaccard across 20 cells (M=4..64, T=200..3200) is >=0.95 at
p>=90, so the anchor choice is empirically immaterial in the operating
range.

Counts: p80 keeps 262/328, p90 295/328, p95 312/328, p99 325/328.
Dropped variants are commented out (not deleted) so the YAML carries
the cut provenance. p98/p97 are deliberately omitted because the 9
CCM-with-EmbedDim variants share a cache namespace and tie in
amortized cost; cuts that fall inside the tied block would partially
drop the group with zero time saved.

Also includes the previously-untracked non-amortized benchmarked
configs (80/85/90) and the 85_amortized variant for completeness.
cut_config.amortized_costs was bucketing by _cache_namespace and
spreading the bucket's total cost evenly over every member. But two
classes have caches keyed on a constructor param within the namespace,
so members of different sub-buckets don't actually share work:

- Barycenter: cache is per (mode, pair) — variants only share within
  the same mode (euclidean / dtw / sgddtw / softdtw).
- NonparametricSpectralBivariate (5 directed-spectral classes): each
  class's measure call dominates the shared per-pair Connectivity
  build, so amortization should be per class.

Both classes now declare a _cache_subkey property; amortized_costs and
analyse_cells group by (namespace, *subkey) so the cost of one bucket
isn't smeared across independent caches. Other cache-grouped classes
(Covariance/Precision, CCM, Cointegration, spectral_mv) have the same
bug shape but it doesn't move any cut at the percentiles in use, so
left as-is to keep the change surgical.

Re-cut benchmarked{80,90,95,99}_amortized at M=16,T=800:
- p95 / p99: unchanged (cuts above the affected SPIs).
- p90: 7 swap in / 7 swap out. Now drops hsic, softdtw, te_kraskov_DCE
  (fixed-embedding) — actually expensive at large (M, T) — and keeps
  the 12 cheap Barycenter variants (euclidean/dtw/sgddtw modes) that
  were wrongly dropped together with softdtw. Predicted wall time at
  M=64, T=3200 drops from ~11h to ~4h.
- p80: 12 swap each way. Drops dcoh + gpdcoh classes wholly (they
  belong above the threshold per-class); keeps the same 12 cheap bary
  variants.

Runtime parallel scheduler unaffected: it buckets by _cache_namespace
for worker placement (correct: cache lives per data instance), the
subkey is purely an amortization concept for cut decisions.
… classes

Cache-aware corollary to the previous commit: once the expensive shared
computation runs for one member of a cache bucket, the rest of the
bucket is free at compute time (they're just cheap post-lookup
transforms). Dropping a strict subset wastes information for zero time
saving. New rule in cut_config.py: if any member of a cache bucket is
kept, snap-promote the whole bucket. Kept count may exceed the
percentile target by the size of the promoted partial buckets (header
records both the target and the snap delta).

To make the snap rule visible across all cache classes, declare
_cache_subkey on the remaining ones (the previous commit only did
Barycenter + spectral_bv since they were the ones biting in shipped
cuts):

- Covariance/Precision: (estimator,)
- ConvergentCrossMapping: (embedding_dimension,)
- Cointegration: (method, det_order, k_ar_diff) or (method, autolag,
  maxlag, trend) depending on method
- NonparametricSpectralMultivariate: (class_name,)
- GroupDelay / PhaseSlopeIndex override: (class_name, fmin, fmax)
  matching their narrower cache key

Re-cut at M=16,T=800: only p80 changes (262 -> 267; +5 from snapping
DirectedCoherence, which had 1/6 variants kept at the boundary).
p90/p95/p99 unchanged (no partial buckets existed there).
Delete five configs that no code path references after the recent
cache-aware re-cut:

- benchmarked_config.yaml       (original benchmarked cut, superseded)
- benchmarked80_config.yaml     (non-amortized; raw-cost variant
- benchmarked85_config.yaml      unused)
- benchmarked90_config.yaml
- benchmarked85_amortized_config.yaml (85% tier dropped; p95 covers it)

Verified zero load-time references and that the four bundled subsets
(fast/sonnet/fabfour, all wired into Calculator subset=) still load.
Updated two example invocations in bench/README.md and
bench/run_benchmark.pbs that pointed at benchmarked90_config.yaml to
benchmarked90_amortized_config.yaml.
Adds the newly-completed (M=64, T=800) per-cell JSON (cell_wall ~19.6h
at the full config, n_jobs=1), backfilled with per-SPI category/labels.
Re-ran bench.analyse_cells over the now-21-cell corpus; scaling fits
tightened (median |log error| per SPI: ~14% -> ~7%; total cell-wall
prediction at M=64,T=800 within +10% of observed). All artefacts in
bench/results/analysis/ refreshed accordingly.
willedibam and others added 30 commits August 20, 2026 10:33
…over labels

Three defects left by the phase-regression repair.

*Units.* `C.frequencies` is in Hz, so the fitted slope is radians per Hz and
`slope/(2*pi)` is a delay in **seconds**. A true 4-sample lag came back as 4.0,
2.0 and 1.0 at fs = 1, 2 and 4, while the API, the identifier and every other
lagged SPI in pyspi count samples. The delay is scaled by fs; no change at the
shipped fs=1.

*Fit quality.* `rvalue` stored the signed regression r symmetrically, but the
fit is of the phase of C_ij and phase(C_ji) = -phase(C_ij), so reversing the
process order turned +0.99997 into -0.99997 at the mirrored position -- for a
statistic declared symmetric. It is now |r|, which is orientation-free, and the
variant is labelled undirected and unsigned to match.

*Labels.* A structural trait now replaces `directed`/`undirected` in the merged
label set rather than sitting beside a stale one. `gd_*` carried the class's
`antisymmetric` and the config's `directed` simultaneously, so `filter_spis`
answered both ways for the same SPI; six SPIs were in that state.

`gd_*_delay` values are unchanged at fs=1, which is what every bundled config
uses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eshold

`sigonly` is a pointwise amplitude cut -- keep the lags with
`|r(l)| > 1.96/sqrt(T)` -- and the name is a historical misnomer. It is not an
inferential test: the band is not inflated for the series' own autocorrelation,
and it is not corrected for being applied at every one of the ~T/2 lags in the
window. Under the null it therefore keeps something almost surely; no seed in
400 produced an empty surviving set at T=600. Both facts are now stated on the
class and at the call site, and the Bartlett attribution is dropped -- citing
it for the 1/sqrt(T) scale invited exactly the reading the name already
encourages.

When nothing does clear the cut, the statistic is now 0. Falling back to the
unfiltered window reported the largest of ~T/2 sample correlations under the
null -- 0.128 for `max` on the test fixture, 0.191 squared -- which is the
opposite of what a threshold is for.

Tests exercise the returned SPI values rather than the cached lag profile, and
cover the no-survivor case for all four enabled combinations, the sign of the
association for positive and negative coupling, and the cut itself
reconstructed independently from the profile.

The CHANGELOG said eight `xcorr_*` SPIs change; `full` contains six.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reverts the `_rmse` -> `_norm-rootT` rename for non-Euclidean pairwise metrics.
The suffix is pyspi's house label for "divided by sqrt(T)", shared with
`xpdist` and `DynamicTimeWarping(normalise=True)`, and renaming it for one of
the three introduced a second convention for a quantity no bundled config
computes. It is documented instead: literal RMSE for `euclidean`, the
normalisation and nothing more for cityblock, cosine, chebyshev, canberra and
braycurtis.

`CrossPairwiseDistance` is documented as what it is -- the root-T-normalised
cost of a lag-shifted alignment path with the boundary samples stuttered
against the opposite series' endpoint -- and explicitly *not* a conventional
path-length RMSE, since the divisor is the record length while the path has
T + t steps. The stutter is what makes the alignment a valid DTW path, so
`dtw_rmse <= xpdist` holds by construction; that is now a randomised test over
five seeds rather than a claim in a comment.

`tau` was validated as `int(tau) < 0`, which accepted 1.7 (truncated to 1) and
True (silently 1) and raised an opaque conversion error on nan/inf. Validation
moves to `utils.require_int`, now shared with the transfer-entropy embedding
parameters so the rule is one rule: integral, not boolean, finite, in range.

Tests add the definition written out by hand, symmetry and process permutation,
bivariate/multivariate equality, and the tau=0 identity with
`pdist_euclidean_rmse`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pyspi used exactly four functions from `cdt.causality.pairwise`: the ANM
independence score (HSIC of a GP residual against the cause), the conditional
distribution similarity statistic, the RECI regression-error score and IGCI.
They are transcribed into `pyspi/lib/pairwise_causal.py` on
NumPy/SciPy/scikit-learn, with the original algorithm attributions and cdt's
MIT licence retained at `pyspi/lib/LICENSE-cdt.txt` and shipped in the wheel.

**No value changes.** Verified against cdt 0.6 over 60 mixed random pairs
(continuous, random-walk, quantised, independent, various lengths): max
|cdt - local| is exactly 0 for all four scores. Five of those pairs are frozen
into `tests/data/fixtures/cdt_pairwise_reference.npz` so the evidence outlives
the dependency, and the slow baseline suite shows no `anm`/`cds`/`reci`/`igci`
drift. The ANM score itself was already pyspi's own function, monkey-patched
over cdt's (which regressed the wrong way round -- cdt PR #155); only the HSIC
came from cdt.

Torch drove no pyspi computation. It entered because cdt eagerly imports its
Torch-backed models at package load; `InterDependenceScore` is NumPy, and the
comment in `_parallel` saying otherwise is corrected along with the now-dead
cdt/torch thread pinning.

Measured, rather than assumed:

- Installed size 1.1 GB -> 546 MB (torch 370 MB plus triton and cdt).
- `import cdt.causality.pairwise` cost 1.5-1.7 s, paid once per process --
  which under `compute(n_jobs=N)` was once per worker.
- Per-call runtime is unchanged: hsic 0.78 -> 0.75 ms, cds 5.38 -> 5.40 ms,
  reci 0.387 -> 0.386 ms, igci 1.00 -> 0.89 ms at T=800. The win is install
  size and start-up, not throughput.

Tests are three kinds kept separate: independent formulas (an explicit double
sum for HSIC, `lstsq` for RECI, a direct order-statistic sum for the IGCI
entropy), invariants and known directions, and the frozen cdt fixture as
regression evidence. CDS's directional claim is asserted only where it holds --
it goes the wrong way on a cubic pair in 8 of 8 seeds, being one heuristic
feature of the Jarfo model rather than a consistent estimator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… policy

*Benchmark data seeds no longer depend on `n_jobs`.* The n_jobs sweep exists to
time the same problem at different worker counts, and seeding on n_jobs handed
each column of that sweep a different dataset -- so a scaling curve compared
runs on data that were never the same. `n_jobs` stays in the cell *identity*,
because it is what the cell measures.

*Resume identity gains the algorithm and loses the repeat count.* It now covers
`COMPUTATION_VERSION` and the pyspi git sha, so a cell measured before an
output-changing change is not reused after it. `repeats` is dropped from it:
reuse is already allowed when the stored run has at least as many repeats as
requested, so folding the requested count into the identity would discard a
good 10-repeat cell the moment someone asked for 5.

*`Data.add_process` could create a duplicate name.* It appended
`proc-<index>` unconditionally, so a caller who named their processes
`["a", "proc-2"]` and appended twice produced a second `proc-2` --
which `to_frame()` cannot stack, and which the constructor would have rejected.
It now falls past any name already in use.

*The CLI fails on partial failure by default.* `--fail-on-error` is replaced by
`--allow-partial`, inverting the default. The previous argument -- that a few
SPIs legitimately fail on real data, so a non-zero exit would be the normal case
-- no longer holds for the shipped configs, which now run clean on every bundled
fixture and on the default smoke data; and it was the weaker consideration
against a pipeline silently ingesting a table with failed columns. The partial
table is still written, and the failed identifiers still go to stderr and into
the NPZ.

*`setuptools>=77`* in build-requires, matching the PEP 639 licence metadata
already in use; older setuptools would build a wheel with no licence metadata.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* `tests/README.md` said drift is reported and not enforced. It is enforced --
  tolerance violations fail, an all-NaN baseline is rejected, and two gates
  require that no SPI raises and none produces an empty column. The
  per-module tolerance split it described is also gone.
* The CLI's `--mp-context` help claimed spawn is the default everywhere;
  `_default_mp_context` returns fork on Linux, spawn elsewhere.
* The nats standardisation note claimed the bits/nats split made "correlating,
  ranking or thresholding" wrong across estimators. Pearson and Spearman
  between two columns are invariant to a positive rescaling and were never
  affected; what was affected is any comparison of magnitudes -- a shared
  threshold, a difference or ratio, a cross-estimator "which found the most"
  ranking.
* `tools/measure_reproducibility.py` compared only entries finite in both runs,
  so an SPI whose NaN *pattern* moved would have been scored as reproducing
  exactly. It compares the masks first now; re-measured, still 325/325.
* `test_bivariate_rejects_indices_passed_positionally` was `xfail(strict=True)`
  against a bug fixed earlier in this branch, and expected the old `TypeError`.
  It is now a passing test asserting the message that names the signature.
  `test_state_integrity`'s and `test_structural_traits`' docstrings counted
  open findings that no longer exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every changed statistic was accounted for before regenerating, not after:

* **Renamed, 3 SPIs on all three fixtures.** `te_kraskov_*_k-max-*` and
  `gc_gaussian_k-max-*` gain the auto-embedding method. Their values change
  too, because `MAX_CORR_AIS` now selects the source embedding instead of
  hard-coding it to (1, 1).
* **Changed, 4 SPIs on kuramoto_M7_T100 only.** The `xcorr_*_sig-True`
  variants, max |delta| 0.195. Traced to the mechanism, not assumed: 16 of 42
  pairs have no lag clearing `1.96/sqrt(100) = 0.196`, and those now return 0
  instead of the unfiltered-window statistic. The old values at exactly those
  pairs ran 0.109-0.195 -- every one of them below the cut the same code had
  just applied.
* **Nothing else moved.** In particular `gd_*` is unchanged, confirming the
  sample scaling is the no-op at fs=1 that it was predicted to be, and no
  `anm`/`cds`/`reci`/`igci` value moved when cdt was removed.

`COMPUTATION_VERSION` becomes `3.0.0.r2` -- `<release>.r<revision>`, a counter
within a release, since only equality is ever tested. One bump for the final
output-changing state, covering the KSG per-occurrence dither, full
MAX_CORR_AIS, the group-delay scaling and |r|, and the cross-correlation
threshold. Checkpoints and run digests from any earlier state of this branch are
now refused rather than resumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… changes

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
**Boundary.** The AIS scorer computed `N_eff = T - (dim-1)*delay`, but
`_ksg_ais` aligns a one-step-ahead future against the embedding and so spends a
sample on the shift as well as on the lookback: the aligned arrays have
`T - 1 - (dim-1)*delay` rows. At T=5, kNN=4, dimension 1 the guard therefore saw
N=5 (usable neighbours 4, exactly k) and passed, while the real N is 4 (usable
3). `_ksg_mi_general` had no guard of its own, so `tree.query(..., k=5)` padded
with infinities and the invalid candidate scored a finite **0.0**. Three
changes: the scorer uses the aligned count, `_ksg_mi_general` validates the
arrays it was actually handed, and `_select_embedding` raises when no candidate
is scorable instead of returning (1, 1) -- an embedding the scorer had just
rejected, handed to the estimator as though it had been selected.

No shipped value moves: at T=100 with k_max=10, tau_max=4 the smallest aligned
N is 63 against k=4, so no candidate was near the boundary. The slow suite
passes against the existing baselines, so no regeneration and no
COMPUTATION_VERSION bump -- this is stricter rejection of inputs that were
already invalid.

**Oracle.** The Gaussian TE test built its source design from lags 1..l with a
lookback of `l*l_tau`, one sample older throughout than the alignment
`_te_build_embeddings` declares (lags 0..l-1, lookback `(l-1)*l_tau`). It passed
at rel=2e-3 because on a smoothly autocorrelated source the two lag sets carry
nearly the same predictive information. The oracle is rebuilt to the declared
alignment and the fixture changed to a *white* source driving the target at
lag 1, where the two designs differ by far more than the tolerance -- the test
asserts that gap explicitly, so the fixture cannot quietly stop discriminating.
Tolerance tightens from 2e-3 to 1e-6, the residual being the documented 1e-8
covariance ridge. The implementation was correct; the evidence was not.

Also adds an O(N^2) multivariate KSG reference and uses it to check `_ksg_ais`
candidate scores at four (dimension, delay) settings and the selected embedding
against an independently computed argmax.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`int()` and `float()` accept far more than the parameter contracts do:
`int(2.7)` is 2, `int(True)` is 1, `float(True)` is 1.0, and `float("inf")`
sails through a `<= 0` check. Each turns a plainly wrong argument into a
plausible one, silently.

Integral, non-boolean, at or above their minimum: `prop_k` (>=1), numeric
`dyn_corr_excl` (>=0, with `None` and the exact string `"AUTO"` as the only
alternatives -- `"auto"`, `"AUTO "` and `"10"` are now rejected), `n` on
`CausalEntropy`/`DirectedInfo` (>=1), the TE embedding and search parameters
(already strict, unchanged), `LaggedCorrelation.tau` (>=0),
`sakoe_chiba_radius` (>=1) and `CrossPairwiseDistance.tau` (>=0).

Finite real, non-boolean, strictly positive: `kernel_width` and
`sakoe_chiba_ratio`. These are genuinely continuous, so a fractional value
stays legitimate -- a test asserts that too, so the strictness cannot quietly
break the case it exists to allow.

One shared pair of validators in `utils` (`require_int`,
`require_positive_float`), so the rule is one rule rather than a per-class
habit. The validated value is what reaches the identifier, the cache key and
the computation: `prop_k=np.int64(6)` gives `mi_kraskov_NN-6` and a cache key
of plain `int`s, not a numpy type stringified into the name.

Internal calculated indices and the calculators' own `setProperty` paths are
untouched -- they are not public parameters.

No output changes: this is stricter rejection of inputs that were already
invalid, so no baseline regeneration and no COMPUTATION_VERSION bump.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
**The closed form was wrong.** With `x == y` exactly, every joint L-infinity
distance equals the marginal one, so the kth joint radius *is* the kth marginal
radius and strict counting gives `n_x = n_y = k - 1`, not k. The degenerate
estimate is therefore `psi(N) - psi(k)`, not `psi(k) - 2*psi(k+1) + psi(N)` --
which is off by `1/k` and did not match the numbers printed beside it (4.234 vs
a measured 4.7341 at N=400). `psi(N) - psi(4)` reproduces all four measurements
to four decimals. Corrected in the code comment, the test and the CHANGELOG, and
the test now asserts the estimate is *away* from that branch rather than only
near the true entropy.

**The scope claim was too broad.** Per-occurrence dither changes only paths
containing byte-identical normalised coordinate occurrences -- duplicate or
exactly collinear processes, a source equal to its target. It does not change
ordinary KSG results over distinct coordinates, and this is why no bundled
baseline moved even though appending the replica index changed the dither key
for every column: only the integer neighbour counts enter the estimate, and a
1e-8 perturbation does not move which points fall inside the radius. Now a test:
the same continuous pair returns the identical 15 significant figures under four
different dither seeds.

**CDT/Torch references finished off** -- README install note and worker-pinning
paragraph, the benchmark dependency fingerprint, the PBS comment, and the IDS
docstring's `torch.Tensor` parameter types. The two vendored MIT notices are now
in the wheel's `dist-info/licenses` as well as in package data.

**Remaining claims corrected without renaming anything:** `/sqrt(T)` does not
make arbitrary metrics comparable across record lengths (only the Euclidean norm
grows as sqrt(T)); the Bartlett attribution is gone from `sigonly`, which is a
historical pointwise amplitude threshold; the cross-correlation `max` docstring
says "largest retained positive correlation" rather than "strongest
association"; `~328` becomes 325 in three places; `tests/README.md` now
describes enforced drift, nats, and the single remaining xfail -- recorded
explicitly as a fixture/low-data finding on `var1_M3_T100`, not a proven
universal defect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
**IGCI is Information-Geometric Causal Inference**, not "conditional
independence" -- it tests no conditional independence. `InformationGeometric
CausalInference` is the accurate name; the old one is retained as a subclass
that emits a `DeprecationWarning`, so existing configs and scripts keep working
with an identical identifier and identical values.

Its score is a difference of two entropies and therefore exactly antisymmetric
(`A[i,j] == -A[j,i]`, asserted on a fixture). Reporting it as `Unsigned` was not
cosmetic: `Calculator._rmmin` shifts every column it believes unsigned by that
column's minimum, which on an antisymmetric matrix moves both orientations
equally and destroys the sign, and `set_group` folds the directions together
through `abs()`. It is now signed and antisymmetric, and stays commented out of
the bundled configs -- this is metadata correctness, not a claim that the
heuristic is reliable.

**ANM was labelled `linear`** while fitting a Gaussian process and testing
independence with an RBF-kernel HSIC. Now `nonlinear`.

**Structural traits win over config-declared directedness.** The rule was
one-sided -- `antisymmetric`/`asymmetric` displaced `directed`/`undirected` --
so a family-level `directed` could still sit on a statistic that declares itself
undirected. `gd_*_rvalue` is symmetric by construction (it stores |r|) and came
back both `undirected` and `directed` when loaded through YAML, so
`filter_spis` answered either way for the same SPI. The SPI's own trait, taken
from its instance labels before the family and per-config labels are merged, now
displaces the others in both directions. Signedness continues to follow
`issigned()`.

No shipped label changes: the 325 SPIs in `full` already had consistent traits,
and IGCI is disabled. Tests cover direct construction and YAML loading for all
three GroupDelay statistics, plus a sweep asserting no SPI in any of the eight
bundled configs carries two directedness traits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`run_digest` hashed `run_spec` verbatim, and `run_spec` carries `config` (the
name) and `configfile` (the *absolute resolved path*). The same config contents
over the same data therefore digested differently in a source checkout, in an
installed wheel and in a temporary directory -- so a perfectly reusable
checkpoint was refused. A false negative rather than unsafe reuse, but it
defeats the point of a content hash.

Location-only fields (`config`, `configfile`, `dataset_name`) are excluded from
the digest and stay in `run_spec` as provenance. What is hashed is what the run
computes: preprocessing flags, process count and names, observation count, the
SPI identifier set, the config *contents*, `COMPUTATION_VERSION`, and the
dataset bytes in their native dtype.

Tests pin both directions -- identical contents at two paths under two names
digest the same; changed contents, changed data, permuted process order,
changed preprocessing and a changed computation version each digest
differently. The data and permutation cases needed a non-degenerate fixture:
`arange(60).reshape(3, 20)` z-scores to three identical rows, so permuting them
is a no-op and a constant shift washes out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A compact suite over nine deterministic M=3 fixtures at T=64 and T=256 from two
fixed seeds -- independent and correlated Gaussian, a directionally coupled VAR,
nonlinear additive coupling, binary and four-level quantised, a noisy delayed
oscillation, heavy-tailed with deterministic outliers, and duplicate plus
near-collinear processes. Twenty representative SPIs are routed to the fixtures
they can say something about; nothing runs the full config, and only the CCM
representative is marked slow. It enforces the contract that a statistic either
produces a defensible result or fails explicitly, and it found four things.

**A real defect.** KSG per-coordinate rescaling invariance failed on quantised
data. `x` and `1000*x` normalise to columns differing by one ulp (1.1e-16),
which flipped the dither key, re-drew the whole dither, and on tied data a
different dither separates the ties differently: MI moved by 0.0375 nats under
a rescaling the estimator is supposed to be invariant to. The key is now taken
from the column rounded to 12 decimals. This is the only change here that can
move a valid output -- on tied or quantised input -- so COMPUTATION_VERSION
becomes 3.0.0.r3. Tie-free values are unaffected (only the integer neighbour
counts enter the estimate) and no baseline moved; the frozen fixtures are
continuous.

**A test of mine that was vacuous.**
`test_the_dither_realisation_does_not_move_a_continuous_estimate` patched the
module attribute `_KNN_NOISE_SEED`, which is a *default argument* bound when
`_knn_condition` was defined -- so it asserted that a function returns the same
value when called twice with the same arguments. It now passes the seed
explicitly and compares against the brute-force reference.

**Two assumptions of mine that were wrong, recorded rather than asserted away.**
ANM is *not* scale-invariant: the independence test standardises, but the fit is
a GaussianProcessRegressor on raw values with a length scale bounded away from
zero, so rescaling moves the residual (0.27 on this fixture under `4x + 3`).
That matches cdt 0.6 exactly and is a property of the method; pyspi's default
z-scoring collapses it to ~4e-5, and a test now pins both halves. RECI prefers
the *wrong* direction on a saturating tanh pair at both seeds -- its guarantee
needs the cause near-uniform after min-max scaling -- so direction is asserted
for ANM only, and RECI's correct case stays on the cubic pair in
test_pairwise_causal.

**One documented exception.** `gd_delay` is entirely NaN on the four
structureless fixtures, which is correct: group delay is defined only where the
coherence is significant. Listed in `EXPECTED_EMPTY`, and the suite fails if a
listed entry starts producing values.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CCM with E=None always uses E=10 (max of E column, not argmax of rho) Migrate CCM SPIs from pyEDM 1.x to 2.x (drop setuptools<81 pin)

1 participant