Skip to content

Release v0.3.0: drift-detection features, calibration metrics, and bug fixes - #771

Merged
amrit110 merged 18 commits into
mainfrom
chore/v0.3.0-improvements
Aug 5, 2026
Merged

Release v0.3.0: drift-detection features, calibration metrics, and bug fixes#771
amrit110 merged 18 commits into
mainfrom
chore/v0.3.0-improvements

Conversation

@amrit110

@amrit110 amrit110 commented Aug 4, 2026

Copy link
Copy Markdown
Member

Summary

A broad pass over the codebase focused on the cyclops.monitor (drift detection) module, since it's the most novel/differentiated part of this toolkit, plus targeted fixes and new features across evaluate, report, models, data, and repo infrastructure. 16 commits, each independently reviewed, tested, and pushed.

New features

  • Subgroup drift decomposition (Detector.detect_shift_by_subgroup()): runs drift tests independently per subgroup (age band, sex, hospital site, ...) instead of only on the aggregate population — a model can look stable overall while drifting badly for one group, which matters for health-equity-aware monitoring. Includes Bonferroni correction and a minimum-sample-size guard.
  • Drift explanations (DCTester.explain_shift()): SHAP-based explanation of why a domain-classifier-based drift test fired, ranking features by how strongly they indicate a sample belongs to the shifted distribution.
  • Calibration metrics (BinaryBrierScore/MulticlassBrierScore, BinaryCalibrationError): closes a real gap for clinical risk models — discrimination metrics like AUROC say nothing about whether a predicted probability can be trusted at face value.

Bug fixes (all with regression tests)

  • cyclops.monitor: matplotlib color-cycling crash, a Bonferroni-correction state-mutation bug that compounded across repeated calls, a silently-misaligned alibi-detect wrapper (missing preprocess_at_init shifted every later positional argument by one slot), a Reductor crash without torchvision installed.
  • cyclops.report: two export() crash paths (no metrics logged; non-torchmetrics-style metric names), silent report-overwriting on repeated export() calls, an unnecessary unescaped-HTML render path for citation text.
  • cyclops.models: MLPModel (and the packaged "mlp_pt" config) couldn't be constructed at all — three separate bugs (wrong activation instantiation, a double-wrapped layer list, wrong hidden-layer input dimension).
  • cyclops.data: SliceSpec's day-of-month datetime filter silently matched on year instead (a wrong-results bug, not a crash, in code used throughout evaluate/monitor/report slicing).
  • cyclops.utils: exchange_extension() dropped the filename for extensionless paths.

Test coverage

  • evaluate() and evaluate_fairness() (989 lines) had zero test coverage despite being the module's main public entry points — added integration tests covering slicing, fairness grouping, empty-slice handling, and error paths.
  • Added tests for the new MLPModel/mlp_pt fixes and the SliceSpec day-filter fix (self-contained, no database required, unlike the existing integration-marked datetime tests).

Cleanup

  • Removed ~470 lines of dead/unused temporal-modeling scaffolding from cyclops.monitor.utils that was neither exported, imported elsewhere, nor tested.

Infra / docs

  • Added a CodeQL workflow for Python static security scanning.
  • Added a uv Dependabot ecosystem entry (previously only github-actions was covered).
  • Fixed a dead README badge linking to a deleted workflow.
  • Fixed drift-detection API discoverability in the docs (missing autosummary entries, an orphaned tutorial page not in any toctree, no cross-link between the two "monitoring" concepts in the repo).
  • Expanded CONTRIBUTING.md (environment setup, running tests, repo layout) and added CHANGELOG.md.
  • Bumped version to 0.3.0.

Test plan

  • Full unit test suite (9938 tests, pytest -m "not integration_test") passes cleanly on this branch.
  • Each commit individually verified with targeted test runs plus ruff/mypy before pushing.
  • CI (code checks, unit tests, package smoke test) — will run automatically on this PR.

🤖 Generated with Claude Code

amrit110 and others added 18 commits August 4, 2026 15:33
- errorfill(): matplotlib removed ax._get_lines.prop_cycler; use
  get_next_color() instead, which was crashing any plot call with
  color=None.
- TSTester.test_shift(): stop mutating p_val_threshold in place on every
  call (Bonferroni correction was compounding across repeated calls in
  Detector's loops); also guard against UnboundLocalError when X_t isn't
  a plain ndarray.
- ContextMMDWrapper: was missing preprocess_at_init in its positional
  arg list to alibi-detect's ContextMMDDrift, silently shifting every
  later argument by one slot (the reason the ctx_mmd test path was
  skipped as broken). Converted both ContextMMDWrapper and LKWrapper to
  explicit kwargs so future alibi-detect signature changes fail loudly
  instead of silently misaligning.
- Reductor.__init__(): isinstance(transforms, Compose) raises TypeError
  when torchvision isn't installed (Compose is None via
  import_optional_module); guard on Compose is not None. Also fixed
  `device`/`output_path` params typed as `str` with a `None` default.
- Removed plot_label_distribution (unreachable dead code: indexed a
  DataFrame with a literal `None` variable, used icd_counts_pos before
  assignment on one branch, zero test coverage, no callers).
- Removed ~470 lines of unused temporal-modeling scaffolding from
  monitor/utils.py (Data, get_data, run_model, get_serving_data, scale,
  daterange, get_obj_from_str, load_model/save_model, print_metrics_binary,
  load_ckp, get_device, get_temporal_model, Loader, and a stray
  __main__ demo block) - none were exported, imported elsewhere in the
  repo, or tested.

Verified: 23 passed, 1 pre-existing skip in tests/cyclops/monitor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- ModelCardReport.export(): current_report_metrics[0] and
  latest_report_metric_cards[0] were indexed unconditionally, raising
  IndexError whenever a report had no PerformanceMetric logged (e.g. a
  card with only owner/dataset/considerations info).
- _process_metric_name(): raised UnboundLocalError for any metric
  `type` not prefixed with "Binary"/"Multiclass"/"Multilabel" (e.g. a
  custom metric name) since `name` was only assigned inside the
  prefix-matching branches.
- regex_search()/regex_replace(): once the IndexError above is fixed,
  the Overview template still unconditionally indexes
  comp.metric_cards.metrics[0], which Jinja resolves to Undefined for
  an empty list; make both filters tolerate non-string input instead of
  raising TypeError deep in template rendering.

Added regression tests for all three.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
exchange_extension("myfile", "csv") returned ".csv" instead of
"myfile.csv": os.path.splitext returns "" for old_ext on an
extensionless path, and file_path[:-len(old_ext)] evaluates to
file_path[:-0] == file_path[:0] == "" (Python treats -0 as 0).

Also fix test_index_axis, which asserted indices[0] twice instead of
checking indices[1] - a regression in axis-1 handling would have gone
uncaught.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…render

- export()'s default output_filename was the static "model_card.html"
  (and .json), so every export() call into the same output_dir silently
  overwrote the previous report - contradicting the docstring's claim
  that "the file will be named with the current date and time", and
  defeating the trend/history comparison export() itself relies on
  (glob.glob for the most recent prior *.json). Default filename is now
  timestamped per call.
- macros.jinja rendered Citation.content (raw BibTeX text, not HTML)
  with the `|safe` filter, bypassing autoescaping for no reason - BibTeX
  fields are free text that could contain unescaped markup. Graphic.image
  keeps `|safe` since it's documented to hold base64/HTML image content
  by design.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- README linked a badge to .github/workflows/integration_tests.yml,
  which doesn't exist (integration tests need a live synthea/cycquery
  database not available in CI, so that workflow was apparently removed
  without cleaning up the link). Replaced with the unit_tests badge,
  which does exist and wasn't represented in the README at all.
- dependabot.yml only tracked github-actions; added a "uv" ecosystem
  entry so pyproject.toml/uv.lock dependencies get automated update PRs
  too.
- Added a CodeQL workflow (python) - the repo had no static security
  scanning beyond pip-audit's known-vulnerability checks.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CONTRIBUTING.md was 26 lines with no environment setup, test-running,
or repo-layout guidance - previously a new contributor had to reverse
engineer the uv workflow and pytest markers from pyproject.toml. Added
uv sync/pre-commit install steps, how to run unit vs integration tests,
and a one-paragraph-per-module repo layout section.

Added a Keep a Changelog-style CHANGELOG.md, seeded with the fixes made
so far on this branch, to be finalized under a 0.3.0 heading at release
time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- cyclops.monitor.rst's autosummary only listed clinical_applicator and
  synthetic_applicator, omitting Detector, Reductor, TSTester, DCTester,
  and Explainer (the actual public API surface) from the generated API
  reference.
- tutorials_monitor.rst (the drift-detection tutorial page) wasn't
  included in any toctree, making it unreachable from the docs site
  navigation despite existing and linking a real notebook.
- monitoring.rst is titled "Monitoring" but only covers report-card
  performance-over-time tracking; it never mentioned cyclops.monitor's
  statistical drift detection at all, so the two "monitoring" concepts
  in the repo were undiscoverable from each other. Added a cross-link.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add Detector.detect_shift_by_subgroup(), which runs the already-fit
tester independently on each subgroup of a target dataset defined by a
SliceSpec (e.g. age band, sex, hospital site), instead of only testing
the aggregate population.

Motivation: a model can look stable when tested against the whole
target population while drifting badly for a specific clinically or
socially relevant subgroup - an aggregate two-sample test can mask
this entirely (Simpson's-paradox-like effect), which matters a lot for
health-equity-aware monitoring of deployed clinical models. This reuses
the existing SliceSpec machinery already used by ClinicalShiftApplicator
and cyclops.evaluate, so it composes with any existing slicing config.

Includes Bonferroni correction across subgroups (opt-out via
correction="none") to control the false-positive rate from testing many
subgroups at once, and a min_sample_size guard that skips (rather than
unreliably tests) underpowered subgroups while still reporting their
size.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add DCTester.explain_shift(), which explains a detected shift using
SHAP on the domain classifier trained internally by
tester_method="classifier" (Lopez-Paz & Oquab, 2017): that test already
trains a classifier to discriminate reference vs. target samples, so
SHAP on its predict_proba directly answers "which features make a
sample look like it's from the shifted distribution" - the features
most responsible for the detected drift. Returns a dict of feature name
-> mean absolute SHAP value, sorted by descending importance.

Explainer previously existed but was never wired into anything else in
cyclops.monitor. Also fixed a small pre-existing bug in Explainer where
background `data` was silently dropped for the default/generic shap.Explainer
path (only the tree/deep/gradient branches used it).

DCTester.fit() now stores the fitted source data (X_s) so explain_shift
can use it as SHAP background data without requiring it to be passed
again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
cyclops.evaluate.metrics.experimental had no calibration metrics despite
this being one of the biggest gaps for clinical ML evaluation:
discrimination metrics like AUROC say nothing about whether a predicted
probability can be trusted at face value, which matters when risk scores
are acted on directly (e.g. a 30% predicted mortality risk should
correspond to an observed 30% event rate).

Adds, following the existing array-API-agnostic (numpy/torch/cupy)
functional+class metric pattern:
- BinaryBrierScore / MulticlassBrierScore + binary_brier_score /
  multiclass_brier_score: mean squared error between predicted
  probabilities and the (one-hot) target, a proper scoring rule.
- BinaryCalibrationError + binary_calibration_error: bins predicted
  probabilities and measures the gap between average confidence and
  observed accuracy per bin. The default "l1" norm is the standard
  Expected Calibration Error (ECE); "max" gives MCE.

Both support logits (auto-sigmoid), ignore_index, and streaming
update()/compute() accumulation, verified to match sklearn's
brier_score_loss and hand-computed reference values, across numpy and
torch backends. Multiclass calibration error is intentionally out of
scope for this change (top-label vs. per-class averaging is a real
design decision, not a mechanical extension of the binary case).

Full experimental metrics regression suite (9108 tests) still passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…rness()

Both evaluate() (the top-level public API for evaluating models on a
dataset) and evaluate_fairness() (989 lines, the module's fairness/
subgroup evaluation entry point) had zero test coverage - the only
exercise they got was indirect, through cyclops.tasks.classification.

Covers: basic overall evaluation, per-slice results via SliceSpec,
multiple prediction columns, empty-slice behavior (both raise and
warn-with-NaN paths), missing-column validation, DatasetDict split
handling, evaluate()'s fairness_config integration, categorical and
continuous (group_bins) fairness grouping, group_base_values parity,
and invalid-argument error paths.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
MLPModel (and the packaged "mlp_pt" model) was completely unusable:

- get_module("activation", activation) returns the activation *class*
  (e.g. torch.nn.ReLU), not an instance; inserting it directly into
  nn.Sequential raised "list is not a Module subclass" one level up
  because of the next bug, but would have failed on its own regardless
  since nn.Sequential requires Module instances. Now instantiates the
  class, and passes an already-instantiated nn.Module through unchanged
  (matching the documented `Union[str, nn.Module]` type).
- `layers = [self._layer(...)]` wrapped the first hidden layer's
  [Linear, activation] list in another list instead of using it
  directly, so nn.Sequential(*layers) received a list as one of its
  "modules".
- The loop connecting hidden-to-hidden layers used `input_dim` instead
  of `hidden_dims[i]` for the first iteration, silently building a
  Linear layer with the wrong input shape whenever hidden_dims[0] !=
  input_dim (i.e. essentially always, given the default hidden_dims).
- configs/mlp_pt.yaml set `model__layer_dim: 2`, a copy-paste leftover
  from the RNN/GRU/LSTM configs; MLPModel.__init__ has no such
  parameter, so `create_model("mlp_pt", ...).initialize()` raised
  TypeError unconditionally.

Added tests/cyclops/models/neural_nets/test_mlp.py (a previously
untested module) covering construction, multi-layer shape chaining,
nn.Module-instance activations, and the packaged config.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
filter_datetime()'s `day` argument called pc.year(example_values)
instead of pc.day(example_values), so slicing on day-of-month (e.g.
SliceSpec's datetime component slices) silently filtered on year
instead - a wrong-results bug, not a crash, in a function used
throughout evaluate/monitor/report slicing.

The existing parametrized test for this (test_filter_datetime) requires
a live Synthea database and is excluded from CI via
@pytest.mark.integration_test, so this went uncaught. Added a
self-contained unit test (no database) that exercises filter_datetime
directly against a synthetic pyarrow Table.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Full test suite (9938 tests, excluding integration tests requiring a
live database) passes cleanly on this branch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CI on PR #771 failed: "CodeQL analyses from advanced configurations
cannot be processed when the default setup is enabled". The repo
already has GitHub's default CodeQL setup configured (covering python,
javascript-typescript, and actions) via repository security settings,
which isn't visible as a workflow file - my earlier repo audit only
checked .github/workflows/, so I missed it and added a redundant
"advanced configuration" workflow that GitHub refuses to run alongside
the existing default one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CI's doctest hook (python -m doctest) failed two ways after the
explain_shift() change:

1. cyclops/monitor/detector.py's detect_shift_by_subgroup() docstring
   example referenced an undefined `detector` variable - replaced with a
   genuinely runnable example that constructs a real Detector, fits it,
   and calls the method.

2. shap depends on a third-party package also named `slicer`. Python's
   `python -m doctest file1.py file2.py ...` inserts each file's own
   directory onto sys.path and imports it by bare filename before
   restoring sys.path - so doctesting cyclops/data/slicer.py makes it
   importable as bare `slicer`, and that import gets cached in
   sys.modules. If shap is imported anywhere later in the same doctest
   invocation, it picks up our cached `slicer` module instead of the
   real pip-installed one and fails with
   "ImportError: cannot import name 'Alias' from 'slicer'". This was
   latent until this PR touched both cyclops/data/slicer.py and (via
   explain_shift) something that imports shap in the same commit.

   Renaming cyclops/data/slicer.py is out of scope (SliceSpec is public
   API used throughout the codebase). Instead: made
   cyclops/monitor/explainer.py's shap import lazy (deferred to
   Explainer.__init__ instead of module load) so merely importing
   cyclops.monitor doesn't trigger it, and skipped the one doctest line
   that legitimately calls explain_shift() (and therefore always
   imports shap) from execution.

Verified locally by running `python -m doctest` across every file
changed in this PR (in original and reversed order) and across the
entire cyclops/ tree, matching the CI hook exactly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@amrit110
amrit110 merged commit a5da162 into main Aug 5, 2026
9 checks passed
@amrit110
amrit110 deleted the chore/v0.3.0-improvements branch August 5, 2026 13:32
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.

1 participant