core-lens 0.1.dev188__tar.gz → 0.1.dev189__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/PKG-INFO +1 -1
  2. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/_version.py +2 -2
  3. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/base/namespaces/stats.py +222 -120
  4. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/.github/ISSUE_TEMPLATE/blank-proposal.yaml +0 -0
  5. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/.github/ISSUE_TEMPLATE/bug-report.yaml +0 -0
  6. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/.github/ISSUE_TEMPLATE/feature-request.yaml +0 -0
  7. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/.github/pull_request_template.md +0 -0
  8. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/.github/workflows/ci.yml +0 -0
  9. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/.github/workflows/gh-pages.yml +0 -0
  10. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/.github/workflows/pre-release.yml +0 -0
  11. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/.github/workflows/release.yml +0 -0
  12. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/.gitignore +0 -0
  13. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/.gitmessage +0 -0
  14. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/.pre-commit-config.yaml +0 -0
  15. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/.python-version +0 -0
  16. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/CONTRIBUTING.md +0 -0
  17. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/LICENSE +0 -0
  18. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/README.md +0 -0
  19. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/SKILLS.md +0 -0
  20. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/benchmarks/README.md +0 -0
  21. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/benchmarks/bench_aoi.py +0 -0
  22. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/benchmarks/bench_entity.py +0 -0
  23. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/benchmarks/bench_export.py +0 -0
  24. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/benchmarks/bench_polars_utils.py +0 -0
  25. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/benchmarks/bench_result.py +0 -0
  26. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/benchmarks/bench_schema.py +0 -0
  27. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/benchmarks/bench_season.py +0 -0
  28. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/benchmarks/bench_spatial.py +0 -0
  29. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/benchmarks/bench_view.py +0 -0
  30. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/benchmarks/run_all.sh +0 -0
  31. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/docs/Makefile +0 -0
  32. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/docs/make.bat +0 -0
  33. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/docs/source/concepts.md +0 -0
  34. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/docs/source/conf.py +0 -0
  35. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/docs/source/export.md +0 -0
  36. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/docs/source/index.rst +0 -0
  37. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/docs/source/intro.md +0 -0
  38. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/docs/source/logging.md +0 -0
  39. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/docs/source/plots.md +0 -0
  40. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/docs/source/plugins.md +0 -0
  41. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/docs/source/queries.md +0 -0
  42. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/docs/source/quickstart.md +0 -0
  43. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/docs/source/stats.md +0 -0
  44. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/examples/demo_mws.py +0 -0
  45. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/examples/demo_tehsil.py +0 -0
  46. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/hooks/mypy.sh +0 -0
  47. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/hooks/no-parquet-outside-fixtures.sh +0 -0
  48. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/hooks/pytest.sh +0 -0
  49. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/pyproject.toml +0 -0
  50. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/__init__.py +0 -0
  51. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/__main__.py +0 -0
  52. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/aoi.py +0 -0
  53. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/base/__init__.py +0 -0
  54. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/base/entity.py +0 -0
  55. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/base/namespaces/__init__.py +0 -0
  56. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/base/namespaces/plot.py +0 -0
  57. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/base/result.py +0 -0
  58. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/base/view.py +0 -0
  59. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/entities/__init__.py +0 -0
  60. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/entities/farms.py +0 -0
  61. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/entities/mws.py +0 -0
  62. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/entities/tehsil.py +0 -0
  63. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/entities/waterbody.py +0 -0
  64. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/export/__init__.py +0 -0
  65. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/export/formats.py +0 -0
  66. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/py.typed +0 -0
  67. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/schema/__init__.py +0 -0
  68. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/schema/detection.py +0 -0
  69. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/schema/profile.py +0 -0
  70. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/utils/__init__.py +0 -0
  71. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/utils/paths.py +0 -0
  72. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/utils/polars_utils.py +0 -0
  73. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/utils/season.py +0 -0
  74. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/utils/spatial.py +0 -0
  75. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/tests/fixtures/generate_fixtures.py +0 -0
  76. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/tests/unit/conftest.py +0 -0
  77. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/tests/unit/test_aoi.py +0 -0
  78. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/tests/unit/test_entities.py +0 -0
  79. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/tests/unit/test_entity.py +0 -0
  80. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/tests/unit/test_export.py +0 -0
  81. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/tests/unit/test_main.py +0 -0
  82. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/tests/unit/test_plot.py +0 -0
  83. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/tests/unit/test_polars_utils.py +0 -0
  84. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/tests/unit/test_profile.py +0 -0
  85. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/tests/unit/test_result.py +0 -0
  86. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/tests/unit/test_schema_detection.py +0 -0
  87. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/tests/unit/test_schema_profile.py +0 -0
  88. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/tests/unit/test_season.py +0 -0
  89. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/tests/unit/test_season_config.py +0 -0
  90. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/tests/unit/test_spatial.py +0 -0
  91. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/tests/unit/test_stats.py +0 -0
  92. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/tests/unit/test_view.py +0 -0
  93. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/usage.md +0 -0
  94. {core_lens-0.1.dev188 → core_lens-0.1.dev189}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: core-lens
3
- Version: 0.1.dev188
3
+ Version: 0.1.dev189
4
4
  Summary: Query, analyse, and visualise CoreStack's microwatershed and Earth science data through a clean, composable Python API.
5
5
  Project-URL: Homepage, https://github.com/ApoorvaKashyap/core-lens
6
6
  Project-URL: Issues, https://github.com/ApoorvaKashyap/core-lens/issues
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.1.dev188'
22
- __version_tuple__ = version_tuple = (0, 1, 'dev188')
21
+ __version__ = version = '0.1.dev189'
22
+ __version_tuple__ = version_tuple = (0, 1, 'dev189')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -747,6 +747,7 @@ class StatsNamespace:
747
747
  mode: str,
748
748
  method: AnomalyCrossMethod | AnomalyTsMethod,
749
749
  baseline: tuple[int, int] | None = None,
750
+ target_years: tuple[int, int] | None = None,
750
751
  threshold: float = 2.0,
751
752
  ) -> Result:
752
753
  """Anomaly detection in cross-sectional or timeseries mode.
@@ -754,26 +755,40 @@ class StatsNamespace:
754
755
  Args:
755
756
  column (str): Value column to analyse.
756
757
  mode (str): ``"cross_sectional"`` or ``"timeseries"``.
757
- method (AnomalyCrossMethod | AnomalyTsMethod): An :class:`~core_lens.base.namespaces.stats.AnomalyCrossMethod` or :class:`~core_lens.base.namespaces.stats.AnomalyTsMethod` enum value.
758
- baseline (tuple[int, int] | None, optional): ``(from_year, to_year)`` inclusive. Required for
759
- timeseries; optional for cross-sectional.
758
+ method (AnomalyCrossMethod | AnomalyTsMethod): Enum value for detection method.
759
+ baseline (tuple[int, int] | None, optional): ``(from_year, to_year)`` inclusive,
760
+ used as the reference/"normal" period. Required for timeseries; optional
761
+ for cross-sectional.
762
+ target_years (tuple[int, int] | None, optional): ``(from_year, to_year)`` inclusive,
763
+ the years actually scored for anomalies. Timeseries: defaults to "everything
764
+ after ``baseline[1]``" if omitted (old behaviour preserved). Cross-sectional:
765
+ defaults to all rows if omitted. Must not overlap ``baseline``.
760
766
  threshold (float, optional): Sigma / score threshold for anomaly flag (default 2.0).
761
767
 
762
768
  Returns:
763
- Result: Result whose data has ``key_col | anomaly_score | is_anomaly``
764
- (cross-sectional) or ``key_col | year | anomaly_score | is_anomaly``
765
- (timeseries, baseline period excluded).
769
+ Result: data has ``key_col | anomaly_score | is_anomaly`` (cross-sectional) or
770
+ ``key_col | year | anomaly_score | is_anomaly`` (timeseries, baseline excluded,
771
+ restricted to ``target_years`` if given). ``metadata`` includes
772
+ ``n_entities_dropped`` (entities skipped for insufficient baseline obs).
766
773
 
767
774
  Raises:
768
- ValueError: If ``mode``, ``method``, or observation count invalid.
769
-
770
- Under the hood:
771
- - Most anomaly methods (ZSCORE, IQR, PERCENTILE, THRESHOLD, MAD, CUSUM) are implemented
772
- using native Polars aggregations for high performance.
773
- - The STL method calls ``statsmodels.tsa.seasonal.STL`` to decompose timeseries data.
774
-
775
+ ValueError: If ``mode``/``method`` invalid, observation count too low, or
776
+ ``baseline`` and ``target_years`` overlap.
775
777
  """
778
+ if baseline is not None and target_years is not None:
779
+ b_lo, b_hi = baseline
780
+ t_lo, t_hi = target_years
781
+ if b_lo <= t_hi and t_lo <= b_hi:
782
+ raise ValueError(
783
+ f"StatsNamespace.anomaly: baseline={baseline} and "
784
+ f"target_years={target_years} overlap. They must be disjoint."
785
+ )
786
+
787
+ # Single explicit collect boundary — everything below is eager by necessity
788
+ # (numpy/scipy/statsmodels have no lazy path).
776
789
  df = self._r.df()
790
+ if isinstance(df, pl.LazyFrame):
791
+ df = df.collect()
777
792
  key = self._r.key_cols[0]
778
793
 
779
794
  if mode == "cross_sectional":
@@ -783,13 +798,13 @@ class StatsNamespace:
783
798
  f"Valid options: {[e.name for e in AnomalyCrossMethod]}."
784
799
  )
785
800
 
786
- # baseline subset for computing reference stats
787
- if baseline is not None and "year" in df.columns:
801
+ has_year = "year" in df.columns
802
+
803
+ if baseline is not None and has_year:
788
804
  ref_vals = (
789
- df.filter(
790
- (pl.col("year") >= baseline[0])
791
- & (pl.col("year") <= baseline[1])
792
- )[column]
805
+ df.filter(pl.col("year").is_between(baseline[0], baseline[1]))[
806
+ column
807
+ ]
793
808
  .drop_nulls()
794
809
  .to_numpy()
795
810
  .astype(float)
@@ -804,7 +819,13 @@ class StatsNamespace:
804
819
  f"but only got {len(ref_vals)}."
805
820
  )
806
821
 
807
- all_vals = df[column].to_numpy().astype(float)
822
+ # target_years scopes which rows get scored/returned; baseline stays reference-only.
823
+ eval_df = df
824
+ if target_years is not None and has_year:
825
+ eval_df = df.filter(
826
+ pl.col("year").is_between(target_years[0], target_years[1])
827
+ )
828
+ all_vals = eval_df[column].to_numpy().astype(float)
808
829
 
809
830
  if method is AnomalyCrossMethod.ZSCORE:
810
831
  _rs = pl.Series(ref_vals)
@@ -816,23 +837,23 @@ class StatsNamespace:
816
837
  "mode": "cross_sectional",
817
838
  "method": "zscore",
818
839
  "baseline": baseline,
840
+ "target_years": target_years,
819
841
  "baseline_mean": mean,
820
842
  "baseline_std": std,
821
843
  }
822
844
 
823
845
  elif method is AnomalyCrossMethod.IQR:
824
846
  _rs = pl.Series(ref_vals)
825
- q1 = _sf(_rs.quantile(0.25))
826
- q3 = _sf(_rs.quantile(0.75))
847
+ q1, q3 = _sf(_rs.quantile(0.25)), _sf(_rs.quantile(0.75))
827
848
  iqr = q3 - q1
828
849
  lo, hi = q1 - 1.5 * iqr, q3 + 1.5 * iqr
829
- med = _sf(_rs.median())
830
- scores = (all_vals - med) / (iqr or 1.0)
850
+ scores = (all_vals - _sf(_rs.median())) / (iqr or 1.0)
831
851
  flags = (all_vals < lo) | (all_vals > hi)
832
852
  meta = {
833
853
  "mode": "cross_sectional",
834
854
  "method": "iqr",
835
855
  "baseline": baseline,
856
+ "target_years": target_years,
836
857
  "baseline_mean": _sf(_rs.mean()),
837
858
  "q1": q1,
838
859
  "q3": q3,
@@ -841,16 +862,15 @@ class StatsNamespace:
841
862
 
842
863
  elif method is AnomalyCrossMethod.PERCENTILE:
843
864
  _rs = pl.Series(ref_vals)
844
- lo = _sf(_rs.quantile(0.05))
845
- hi = _sf(_rs.quantile(0.95))
846
- med = _sf(_rs.median())
865
+ lo, hi = _sf(_rs.quantile(0.05)), _sf(_rs.quantile(0.95))
847
866
  std = _sf(_rs.std()) or 1.0
848
- scores = (all_vals - med) / std
867
+ scores = (all_vals - _sf(_rs.median())) / std
849
868
  flags = (all_vals < lo) | (all_vals > hi)
850
869
  meta = {
851
870
  "mode": "cross_sectional",
852
871
  "method": "percentile",
853
872
  "baseline": baseline,
873
+ "target_years": target_years,
854
874
  "baseline_mean": _sf(_rs.mean()),
855
875
  "lower_pct": lo,
856
876
  "upper_pct": hi,
@@ -864,15 +884,15 @@ class StatsNamespace:
864
884
  "mode": "cross_sectional",
865
885
  "method": "threshold",
866
886
  "baseline": baseline,
887
+ "target_years": target_years,
867
888
  "baseline_mean": mean,
868
889
  "threshold": threshold,
869
890
  }
870
891
 
871
- data = df.select(pl.col(key)).with_columns(
872
- [
873
- pl.Series("anomaly_score", scores.tolist(), dtype=pl.Float64),
874
- pl.Series("is_anomaly", flags.tolist(), dtype=pl.Boolean),
875
- ]
892
+ # Build straight from numpy — no .tolist() round-trip.
893
+ data = eval_df.select(pl.col(key)).with_columns(
894
+ pl.Series("anomaly_score", scores, dtype=pl.Float64),
895
+ pl.Series("is_anomaly", flags, dtype=pl.Boolean),
876
896
  )
877
897
 
878
898
  elif mode == "timeseries":
@@ -889,117 +909,199 @@ class StatsNamespace:
889
909
  year_col = self._year_col()
890
910
  if year_col is None:
891
911
  raise ValueError(
892
- "StatsNamespace.anomaly: mode 'timeseries' requires a year/time column. Ensure data is at annual or sub_annual resolution."
912
+ "StatsNamespace.anomaly: mode 'timeseries' requires a year/time column. "
913
+ "Ensure data is at annual or sub_annual resolution."
893
914
  )
894
915
 
895
916
  min_obs = _MIN_OBS[method.value]
896
- rows: list[dict[str, Any]] = []
917
+ n_dropped = 0
897
918
 
898
- for eid in df[key].unique().sort().to_list():
899
- sub = df.filter(pl.col(key) == eid).sort(year_col)
900
- base_vals = (
901
- sub.filter(
902
- (pl.col(year_col) >= baseline[0])
903
- & (pl.col(year_col) <= baseline[1])
904
- )[column]
905
- .drop_nulls()
906
- .to_numpy()
907
- .astype(float)
919
+ # ---- MAD: fully vectorized, no per-entity loop ----
920
+ if method is AnomalyTsMethod.MAD:
921
+ base_df = df.filter(
922
+ pl.col(year_col).is_between(baseline[0], baseline[1])
908
923
  )
909
- if len(base_vals) < min_obs:
910
- continue
924
+ counts = base_df.group_by(key).agg(
925
+ pl.col(column).drop_nulls().len().alias("_n")
926
+ )
927
+ valid_keys = counts.filter(pl.col("_n") >= min_obs).select(key)
928
+ n_dropped = counts.height - valid_keys.height
911
929
 
912
- eval_sub = sub.filter(pl.col(year_col) > baseline[1])
913
- eval_vals = eval_sub[column].drop_nulls().to_numpy().astype(float)
914
- eval_years = eval_sub[year_col].to_list()
915
-
916
- ts_scores: list[float] = []
917
- ts_flags: list[bool] = []
918
-
919
- if method is AnomalyTsMethod.MAD:
920
- _bs = pl.Series(base_vals)
921
- med = _sf(_bs.median())
922
- mad = _sf(pl.Series(np.abs(base_vals - med)).median())
923
- scale = (mad * 1.4826) or 1.0
924
- ts_scores = [(v - med) / scale for v in eval_vals]
925
- ts_flags = [abs(s) > threshold for s in ts_scores]
926
-
927
- elif method is AnomalyTsMethod.CUSUM:
928
- _bs = pl.Series(base_vals)
929
- mean = _sf(_bs.mean())
930
- std = _sf(_bs.std(ddof=1)) or 1.0
931
- k, h = 0.5 * std, threshold * std
932
- cp, cn = 0.0, 0.0
933
- for v in eval_vals:
934
- cp = max(0.0, cp + v - mean - k)
935
- cn = max(0.0, cn - v + mean - k)
936
- s = max(cp, cn)
937
- ts_scores.append(s)
938
- ts_flags.append(s > h)
939
-
940
- else: # stl
941
- try:
942
- from statsmodels.tsa.seasonal import ( # type: ignore[import-untyped]
943
- STL,
944
- )
930
+ med_df = (
931
+ base_df.join(valid_keys, on=key)
932
+ .group_by(key)
933
+ .agg(pl.col(column).median().alias("_med"))
934
+ )
935
+ mad_df = (
936
+ base_df.join(valid_keys, on=key)
937
+ .join(med_df, on=key)
938
+ .with_columns(
939
+ (pl.col(column) - pl.col("_med")).abs().alias("_absdev")
940
+ )
941
+ .group_by(key)
942
+ .agg(pl.col("_absdev").median().alias("_mad"))
943
+ )
944
+ stats_df = med_df.join(mad_df, on=key).with_columns(
945
+ (pl.col("_mad") * 1.4826).clip(lower_bound=1e-12).alias("_scale")
946
+ )
947
+
948
+ eval_df = (
949
+ df.filter(pl.col(year_col) > baseline[1])
950
+ if target_years is None
951
+ else df.filter(
952
+ pl.col(year_col).is_between(target_years[0], target_years[1])
953
+ )
954
+ )
945
955
 
946
- full_vals = sub[column].to_numpy().astype(float)
947
- if len(full_vals) < min_obs:
948
- continue
949
- period = (
950
- 24 if len(full_vals) >= 24 else max(2, len(full_vals) // 2)
956
+ data = (
957
+ eval_df.join(stats_df, on=key, how="inner")
958
+ .with_columns(
959
+ ((pl.col(column) - pl.col("_med")) / pl.col("_scale")).alias(
960
+ "anomaly_score"
961
+ ),
962
+ )
963
+ .with_columns(
964
+ (pl.col("anomaly_score").abs() > threshold).alias("is_anomaly")
965
+ )
966
+ .select(key, year_col, "anomaly_score", "is_anomaly")
967
+ .drop_nulls("anomaly_score")
968
+ )
969
+
970
+ # ---- CUSUM / STL: inherently sequential/per-entity, keep loop but partition_by ----
971
+ else:
972
+ rows: list[dict[str, Any]] = []
973
+ for sub in df.sort(year_col).partition_by(key, maintain_order=True):
974
+ eid = sub[key][0]
975
+ base_vals = (
976
+ sub.filter(
977
+ pl.col(year_col).is_between(baseline[0], baseline[1])
978
+ )[column]
979
+ .drop_nulls()
980
+ .to_numpy()
981
+ .astype(float)
982
+ )
983
+ if len(base_vals) < min_obs:
984
+ n_dropped += 1
985
+ continue
986
+
987
+ if target_years is not None:
988
+ eval_sub = sub.filter(
989
+ pl.col(year_col).is_between(
990
+ target_years[0], target_years[1]
991
+ )
951
992
  )
952
- res = STL(full_vals, period=period).fit()
953
- resid = res.resid
954
- base_len = len(base_vals)
955
- base_resid = resid[:base_len]
956
- eval_resid = resid[base_len : base_len + len(eval_years)]
957
- std = _sf(pl.Series(base_resid).std(ddof=1)) or 1.0
958
- ts_scores = [float(r / std) for r in eval_resid]
959
- ts_flags = [abs(s) > threshold for s in ts_scores]
960
- except Exception:
961
- ts_scores = [float("nan")] * len(eval_years)
962
- ts_flags = [False] * len(eval_years)
963
-
964
- for yr, sc, fl in zip(eval_years, ts_scores, ts_flags):
965
- rows.append(
993
+ else:
994
+ eval_sub = sub.filter(pl.col(year_col) > baseline[1])
995
+ eval_vals = eval_sub[column].drop_nulls().to_numpy().astype(float)
996
+ eval_years = eval_sub.filter(pl.col(column).is_not_null())[
997
+ year_col
998
+ ].to_list()
999
+
1000
+ ts_scores: list[float] = []
1001
+ ts_flags: list[bool] = []
1002
+
1003
+ if method is AnomalyTsMethod.CUSUM:
1004
+ mean = _sf(pl.Series(base_vals).mean())
1005
+ std = _sf(pl.Series(base_vals).std(ddof=1)) or 1.0
1006
+ k, h = 0.5 * std, threshold * std
1007
+ cp, cn = 0.0, 0.0
1008
+ for v in eval_vals:
1009
+ cp = max(0.0, cp + v - mean - k)
1010
+ cn = max(0.0, cn - v + mean - k)
1011
+ s = max(cp, cn)
1012
+ ts_scores.append(s)
1013
+ ts_flags.append(s > h)
1014
+
1015
+ else: # STL
1016
+ try:
1017
+ from statsmodels.tsa.seasonal import STL # type: ignore[import-untyped]
1018
+
1019
+ full = sub.filter(pl.col(column).is_not_null())
1020
+ full_vals = full[column].to_numpy().astype(float)
1021
+ full_years = full[year_col].to_list()
1022
+ if len(full_vals) < min_obs:
1023
+ n_dropped += 1
1024
+ continue
1025
+
1026
+ # Seasonal period: sub-annual cadence assumed 24 steps/year
1027
+ # (e.g. fortnightly). Falls back to half the series length
1028
+ # for shorter records. Adjust if your data's cadence differs.
1029
+ period = (
1030
+ 24
1031
+ if len(full_vals) >= 24
1032
+ else max(2, len(full_vals) // 2)
1033
+ )
1034
+ res = STL(full_vals, period=period).fit()
1035
+ resid = res.resid
1036
+
1037
+ # Match residuals to years EXPLICITLY (not positional slicing) —
1038
+ # positional slicing silently misaligns if there are gaps between
1039
+ # baseline and eval rows.
1040
+ resid_by_year = dict(zip(full_years, resid))
1041
+ base_resid = [
1042
+ resid_by_year[y]
1043
+ for y in full_years
1044
+ if baseline[0] <= y <= baseline[1]
1045
+ ]
1046
+ std = _sf(pl.Series(base_resid).std(ddof=1)) or 1.0
1047
+
1048
+ ts_scores = [
1049
+ float(resid_by_year[y] / std)
1050
+ if y in resid_by_year
1051
+ else float("nan")
1052
+ for y in eval_years
1053
+ ]
1054
+ ts_flags = [
1055
+ abs(s) > threshold if s == s else False
1056
+ for s in ts_scores
1057
+ ] # s==s filters NaN
1058
+
1059
+ except Exception as e:
1060
+ warnings.warn(
1061
+ f"StatsNamespace.anomaly: STL fit failed for entity {eid!r}: {e}"
1062
+ )
1063
+ ts_scores = [float("nan")] * len(eval_years)
1064
+ ts_flags = [False] * len(eval_years)
1065
+
1066
+ for yr, sc, fl in zip(eval_years, ts_scores, ts_flags):
1067
+ rows.append(
1068
+ {
1069
+ key: eid,
1070
+ year_col: yr,
1071
+ "anomaly_score": float(sc),
1072
+ "is_anomaly": bool(fl),
1073
+ }
1074
+ )
1075
+
1076
+ data = (
1077
+ pl.DataFrame(rows)
1078
+ if rows
1079
+ else pl.DataFrame(
966
1080
  {
967
- key: eid,
968
- year_col: yr,
969
- "anomaly_score": float(sc),
970
- "is_anomaly": bool(fl),
1081
+ key: pl.Series([], dtype=pl.String),
1082
+ year_col: pl.Series([], dtype=pl.Int32),
1083
+ "anomaly_score": pl.Series([], dtype=pl.Float64),
1084
+ "is_anomaly": pl.Series([], dtype=pl.Boolean),
971
1085
  }
972
1086
  )
973
-
974
- if rows:
975
- data = pl.DataFrame(rows)
976
- else:
977
- data = pl.DataFrame(
978
- {
979
- key: pl.Series([], dtype=pl.String),
980
- year_col: pl.Series([], dtype=pl.Int32),
981
- "anomaly_score": pl.Series([], dtype=pl.Float64),
982
- "is_anomaly": pl.Series([], dtype=pl.Boolean),
983
- }
984
1087
  )
1088
+
985
1089
  global_base_vals = (
986
- df.filter(
987
- (pl.col(year_col) >= baseline[0])
988
- & (pl.col(year_col) <= baseline[1])
989
- )[column]
1090
+ df.filter(pl.col(year_col).is_between(baseline[0], baseline[1]))[column]
990
1091
  .drop_nulls()
991
1092
  .to_numpy()
992
1093
  .astype(float)
993
1094
  )
994
-
995
1095
  meta = {
996
1096
  "mode": "timeseries",
997
- "method": method.value if method is not None else None,
1097
+ "method": method.value,
998
1098
  "baseline": baseline,
1099
+ "target_years": target_years,
999
1100
  "baseline_mean": _sf(pl.Series(global_base_vals).mean())
1000
1101
  if len(global_base_vals) > 0
1001
1102
  else float("nan"),
1002
1103
  "baseline_fitted": True,
1104
+ "n_entities_dropped": n_dropped,
1003
1105
  }
1004
1106
 
1005
1107
  else:
File without changes
File without changes
File without changes
File without changes
File without changes