core-lens 0.1.dev188__tar.gz → 0.1.dev190__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.dev190}/PKG-INFO +1 -1
  2. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/_version.py +2 -2
  3. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/base/namespaces/stats.py +244 -118
  4. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/.github/ISSUE_TEMPLATE/blank-proposal.yaml +0 -0
  5. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/.github/ISSUE_TEMPLATE/bug-report.yaml +0 -0
  6. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/.github/ISSUE_TEMPLATE/feature-request.yaml +0 -0
  7. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/.github/pull_request_template.md +0 -0
  8. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/.github/workflows/ci.yml +0 -0
  9. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/.github/workflows/gh-pages.yml +0 -0
  10. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/.github/workflows/pre-release.yml +0 -0
  11. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/.github/workflows/release.yml +0 -0
  12. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/.gitignore +0 -0
  13. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/.gitmessage +0 -0
  14. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/.pre-commit-config.yaml +0 -0
  15. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/.python-version +0 -0
  16. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/CONTRIBUTING.md +0 -0
  17. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/LICENSE +0 -0
  18. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/README.md +0 -0
  19. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/SKILLS.md +0 -0
  20. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/benchmarks/README.md +0 -0
  21. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/benchmarks/bench_aoi.py +0 -0
  22. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/benchmarks/bench_entity.py +0 -0
  23. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/benchmarks/bench_export.py +0 -0
  24. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/benchmarks/bench_polars_utils.py +0 -0
  25. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/benchmarks/bench_result.py +0 -0
  26. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/benchmarks/bench_schema.py +0 -0
  27. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/benchmarks/bench_season.py +0 -0
  28. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/benchmarks/bench_spatial.py +0 -0
  29. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/benchmarks/bench_view.py +0 -0
  30. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/benchmarks/run_all.sh +0 -0
  31. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/docs/Makefile +0 -0
  32. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/docs/make.bat +0 -0
  33. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/docs/source/concepts.md +0 -0
  34. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/docs/source/conf.py +0 -0
  35. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/docs/source/export.md +0 -0
  36. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/docs/source/index.rst +0 -0
  37. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/docs/source/intro.md +0 -0
  38. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/docs/source/logging.md +0 -0
  39. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/docs/source/plots.md +0 -0
  40. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/docs/source/plugins.md +0 -0
  41. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/docs/source/queries.md +0 -0
  42. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/docs/source/quickstart.md +0 -0
  43. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/docs/source/stats.md +0 -0
  44. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/examples/demo_mws.py +0 -0
  45. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/examples/demo_tehsil.py +0 -0
  46. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/hooks/mypy.sh +0 -0
  47. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/hooks/no-parquet-outside-fixtures.sh +0 -0
  48. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/hooks/pytest.sh +0 -0
  49. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/pyproject.toml +0 -0
  50. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/__init__.py +0 -0
  51. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/__main__.py +0 -0
  52. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/aoi.py +0 -0
  53. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/base/__init__.py +0 -0
  54. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/base/entity.py +0 -0
  55. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/base/namespaces/__init__.py +0 -0
  56. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/base/namespaces/plot.py +0 -0
  57. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/base/result.py +0 -0
  58. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/base/view.py +0 -0
  59. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/entities/__init__.py +0 -0
  60. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/entities/farms.py +0 -0
  61. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/entities/mws.py +0 -0
  62. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/entities/tehsil.py +0 -0
  63. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/entities/waterbody.py +0 -0
  64. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/export/__init__.py +0 -0
  65. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/export/formats.py +0 -0
  66. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/py.typed +0 -0
  67. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/schema/__init__.py +0 -0
  68. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/schema/detection.py +0 -0
  69. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/schema/profile.py +0 -0
  70. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/utils/__init__.py +0 -0
  71. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/utils/paths.py +0 -0
  72. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/utils/polars_utils.py +0 -0
  73. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/utils/season.py +0 -0
  74. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/utils/spatial.py +0 -0
  75. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/tests/fixtures/generate_fixtures.py +0 -0
  76. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/tests/unit/conftest.py +0 -0
  77. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/tests/unit/test_aoi.py +0 -0
  78. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/tests/unit/test_entities.py +0 -0
  79. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/tests/unit/test_entity.py +0 -0
  80. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/tests/unit/test_export.py +0 -0
  81. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/tests/unit/test_main.py +0 -0
  82. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/tests/unit/test_plot.py +0 -0
  83. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/tests/unit/test_polars_utils.py +0 -0
  84. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/tests/unit/test_profile.py +0 -0
  85. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/tests/unit/test_result.py +0 -0
  86. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/tests/unit/test_schema_detection.py +0 -0
  87. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/tests/unit/test_schema_profile.py +0 -0
  88. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/tests/unit/test_season.py +0 -0
  89. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/tests/unit/test_season_config.py +0 -0
  90. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/tests/unit/test_spatial.py +0 -0
  91. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/tests/unit/test_stats.py +0 -0
  92. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/tests/unit/test_view.py +0 -0
  93. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/usage.md +0 -0
  94. {core_lens-0.1.dev188 → core_lens-0.1.dev190}/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.dev190
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.dev190'
22
+ __version_tuple__ = version_tuple = (0, 1, 'dev190')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -741,12 +741,15 @@ class StatsNamespace:
741
741
  }
742
742
  return self._r._replace(data=data, has_geometry=False, metadata=metadata)
743
743
 
744
+ import warnings
745
+
744
746
  def anomaly(
745
747
  self,
746
748
  column: str,
747
749
  mode: str,
748
750
  method: AnomalyCrossMethod | AnomalyTsMethod,
749
751
  baseline: tuple[int, int] | None = None,
752
+ target_years: tuple[int, int] | None = None,
750
753
  threshold: float = 2.0,
751
754
  ) -> Result:
752
755
  """Anomaly detection in cross-sectional or timeseries mode.
@@ -754,26 +757,39 @@ class StatsNamespace:
754
757
  Args:
755
758
  column (str): Value column to analyse.
756
759
  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.
760
+ method (AnomalyCrossMethod | AnomalyTsMethod): Enum value for detection method.
761
+ baseline (tuple[int, int] | None, optional): ``(from_year, to_year)`` inclusive,
762
+ used as the reference/"normal" period. Required for timeseries; optional
763
+ for cross-sectional.
764
+ target_years (tuple[int, int] | None, optional): ``(from_year, to_year)`` inclusive,
765
+ years actually scored. Timeseries: defaults to "everything after
766
+ ``baseline[1]``" if omitted. Cross-sectional: defaults to all rows.
767
+ Must not overlap ``baseline``.
760
768
  threshold (float, optional): Sigma / score threshold for anomaly flag (default 2.0).
761
769
 
762
770
  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).
771
+ Result: data has ``key_col | anomaly_score | is_anomaly`` (cross-sectional) or
772
+ ``key_col | <time_col> | anomaly_score | is_anomaly`` (timeseries, baseline
773
+ excluded, restricted to ``target_years`` if given). ``<time_col>`` is the
774
+ finer-grained date/period column when the data is sub-annual, falling back
775
+ to plain ``year`` for annual data. ``metadata`` includes ``n_entities_dropped``.
766
776
 
767
777
  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
-
778
+ ValueError: If ``mode``/``method`` invalid, observation count too low, or
779
+ ``baseline`` and ``target_years`` overlap.
775
780
  """
781
+ if baseline is not None and target_years is not None:
782
+ b_lo, b_hi = baseline
783
+ t_lo, t_hi = target_years
784
+ if b_lo <= t_hi and t_lo <= b_hi:
785
+ raise ValueError(
786
+ f"StatsNamespace.anomaly: baseline={baseline} and "
787
+ f"target_years={target_years} overlap. They must be disjoint."
788
+ )
789
+
776
790
  df = self._r.df()
791
+ if isinstance(df, pl.LazyFrame):
792
+ df = df.collect()
777
793
  key = self._r.key_cols[0]
778
794
 
779
795
  if mode == "cross_sectional":
@@ -783,13 +799,13 @@ class StatsNamespace:
783
799
  f"Valid options: {[e.name for e in AnomalyCrossMethod]}."
784
800
  )
785
801
 
786
- # baseline subset for computing reference stats
787
- if baseline is not None and "year" in df.columns:
802
+ has_year = "year" in df.columns
803
+
804
+ if baseline is not None and has_year:
788
805
  ref_vals = (
789
- df.filter(
790
- (pl.col("year") >= baseline[0])
791
- & (pl.col("year") <= baseline[1])
792
- )[column]
806
+ df.filter(pl.col("year").is_between(baseline[0], baseline[1]))[
807
+ column
808
+ ]
793
809
  .drop_nulls()
794
810
  .to_numpy()
795
811
  .astype(float)
@@ -804,7 +820,12 @@ class StatsNamespace:
804
820
  f"but only got {len(ref_vals)}."
805
821
  )
806
822
 
807
- all_vals = df[column].to_numpy().astype(float)
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,14 @@ 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
+ data = eval_df.select(pl.col(key)).with_columns(
893
+ pl.Series("anomaly_score", scores, dtype=pl.Float64),
894
+ pl.Series("is_anomaly", flags, dtype=pl.Boolean),
876
895
  )
877
896
 
878
897
  elif mode == "timeseries":
@@ -889,117 +908,224 @@ class StatsNamespace:
889
908
  year_col = self._year_col()
890
909
  if year_col is None:
891
910
  raise ValueError(
892
- "StatsNamespace.anomaly: mode 'timeseries' requires a year/time column. Ensure data is at annual or sub_annual resolution."
911
+ "StatsNamespace.anomaly: mode 'timeseries' requires a year/time column. "
912
+ "Ensure data is at annual or sub_annual resolution."
893
913
  )
894
914
 
915
+ # Finer-grained time column for sub-annual data, if present.
916
+ # `year_col` still drives baseline/target-year windowing regardless.
917
+ date_col = next(
918
+ (c for c in ("date", "period", "period_start") if c in df.columns), None
919
+ )
920
+ output_time_col = date_col or year_col
921
+
895
922
  min_obs = _MIN_OBS[method.value]
896
- rows: list[dict[str, Any]] = []
923
+ n_dropped = 0
897
924
 
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)
925
+ # ---- MAD: fully vectorized, no per-entity loop ----
926
+ if method is AnomalyTsMethod.MAD:
927
+ base_df = df.filter(
928
+ pl.col(year_col).is_between(baseline[0], baseline[1])
929
+ )
930
+ counts = base_df.group_by(key).agg(
931
+ pl.col(column).drop_nulls().len().alias("_n")
932
+ )
933
+ valid_keys = counts.filter(pl.col("_n") >= min_obs).select(key)
934
+ n_dropped = counts.height - valid_keys.height
935
+
936
+ med_df = (
937
+ base_df.join(valid_keys, on=key)
938
+ .group_by(key)
939
+ .agg(pl.col(column).median().alias("_med"))
940
+ )
941
+ mad_df = (
942
+ base_df.join(valid_keys, on=key)
943
+ .join(med_df, on=key)
944
+ .with_columns(
945
+ (pl.col(column) - pl.col("_med")).abs().alias("_absdev")
946
+ )
947
+ .group_by(key)
948
+ .agg(pl.col("_absdev").median().alias("_mad"))
949
+ )
950
+ stats_df = med_df.join(mad_df, on=key).with_columns(
951
+ (pl.col("_mad") * 1.4826).clip(lower_bound=1e-12).alias("_scale")
908
952
  )
909
- if len(base_vals) < min_obs:
910
- continue
911
953
 
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,
954
+ eval_df = (
955
+ df.filter(pl.col(year_col) > baseline[1])
956
+ if target_years is None
957
+ else df.filter(
958
+ pl.col(year_col).is_between(target_years[0], target_years[1])
959
+ )
960
+ )
961
+
962
+ data = (
963
+ eval_df.join(stats_df, on=key, how="inner")
964
+ .with_columns(
965
+ ((pl.col(column) - pl.col("_med")) / pl.col("_scale")).alias(
966
+ "anomaly_score"
967
+ ),
968
+ )
969
+ .with_columns(
970
+ (pl.col("anomaly_score").abs() > threshold).alias("is_anomaly")
971
+ )
972
+ .select(key, output_time_col, "anomaly_score", "is_anomaly")
973
+ .drop_nulls("anomaly_score")
974
+ )
975
+
976
+ # ---- CUSUM / STL: inherently sequential/per-entity, partition_by instead of filter-loop ----
977
+ else:
978
+ rows: list[dict[str, Any]] = []
979
+ for sub in df.sort(year_col).partition_by(key, maintain_order=True):
980
+ eid = sub[key][0]
981
+ base_vals = (
982
+ sub.filter(
983
+ pl.col(year_col).is_between(baseline[0], baseline[1])
984
+ )[column]
985
+ .drop_nulls()
986
+ .to_numpy()
987
+ .astype(float)
988
+ )
989
+ if len(base_vals) < min_obs:
990
+ n_dropped += 1
991
+ continue
992
+
993
+ if target_years is not None:
994
+ eval_sub = sub.filter(
995
+ pl.col(year_col).is_between(
996
+ target_years[0], target_years[1]
997
+ )
944
998
  )
999
+ else:
1000
+ eval_sub = sub.filter(pl.col(year_col) > baseline[1])
1001
+ eval_sub = eval_sub.filter(pl.col(column).is_not_null())
1002
+
1003
+ eval_vals = eval_sub[column].to_numpy().astype(float)
1004
+ eval_years_lookup = eval_sub[
1005
+ year_col
1006
+ ].to_list() # for baseline/dict matching
1007
+ eval_times = eval_sub[
1008
+ output_time_col
1009
+ ].to_list() # for display in output
1010
+
1011
+ ts_scores: list[float] = []
1012
+ ts_flags: list[bool] = []
1013
+
1014
+ if method is AnomalyTsMethod.CUSUM:
1015
+ mean = _sf(pl.Series(base_vals).mean())
1016
+ std = _sf(pl.Series(base_vals).std(ddof=1)) or 1.0
1017
+ k, h = 0.5 * std, threshold * std
1018
+ cp, cn = 0.0, 0.0
1019
+ for v in eval_vals:
1020
+ cp = max(0.0, cp + v - mean - k)
1021
+ cn = max(0.0, cn - v + mean - k)
1022
+ s = max(cp, cn)
1023
+ ts_scores.append(s)
1024
+ ts_flags.append(s > h)
1025
+
1026
+ else: # STL
1027
+ full = sub.filter(pl.col(column).is_not_null())
1028
+ full_vals = full[column].to_numpy().astype(float)
1029
+ full_years = full[year_col].to_list()
945
1030
 
946
- full_vals = sub[column].to_numpy().astype(float)
947
- if len(full_vals) < min_obs:
948
- continue
949
1031
  period = (
950
- 24 if len(full_vals) >= 24 else max(2, len(full_vals) // 2)
1032
+ 26 if len(full_vals) >= 52 else max(2, len(full_vals) // 2)
951
1033
  )
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(
1034
+ min_required = 2 * period
1035
+ min_reliable = (
1036
+ 3 * period
1037
+ ) # want ~3 cycles minimum for a trustworthy seasonal fit, not just 2
1038
+
1039
+ if len(full_vals) < min_required:
1040
+ warnings.warn(
1041
+ f"StatsNamespace.anomaly: entity {eid!r} has {len(full_vals)} points, "
1042
+ f"period={period} needs >= {min_reliable} for a reliable STL fit. Skipping."
1043
+ )
1044
+ n_dropped += 1
1045
+ continue
1046
+
1047
+ if len(full_vals) < min_required:
1048
+ warnings.warn(
1049
+ f"StatsNamespace.anomaly: entity {eid!r} has {len(full_vals)} points, "
1050
+ f"needs >= {min_required} for period={period}. Skipping."
1051
+ )
1052
+ n_dropped += 1
1053
+ continue
1054
+
1055
+ try:
1056
+ from statsmodels.tsa.seasonal import STL # type: ignore[import-untyped]
1057
+
1058
+ res = STL(full_vals, period=period).fit()
1059
+ resid = res.resid
1060
+
1061
+ # Match residuals to years EXPLICITLY, not positionally —
1062
+ # avoids misalignment if there are gaps in the series.
1063
+ resid_by_year = dict(zip(full_years, resid))
1064
+ base_resid = [
1065
+ resid_by_year[y]
1066
+ for y in full_years
1067
+ if baseline[0] <= y <= baseline[1]
1068
+ ]
1069
+ std = _sf(pl.Series(base_resid).std(ddof=1)) or 1.0
1070
+
1071
+ ts_scores = [
1072
+ float(resid_by_year[y] / std)
1073
+ if y in resid_by_year
1074
+ else float("nan")
1075
+ for y in eval_years_lookup
1076
+ ]
1077
+ ts_flags = [
1078
+ abs(s) > threshold if s == s else False
1079
+ for s in ts_scores
1080
+ ] # s==s filters NaN
1081
+
1082
+ except Exception as e:
1083
+ warnings.warn(
1084
+ f"StatsNamespace.anomaly: STL fit failed for entity {eid!r}: {e}"
1085
+ )
1086
+ ts_scores = [float("nan")] * len(eval_years_lookup)
1087
+ ts_flags = [False] * len(eval_years_lookup)
1088
+
1089
+ for t, sc, fl in zip(eval_times, ts_scores, ts_flags):
1090
+ rows.append(
1091
+ {
1092
+ key: eid,
1093
+ output_time_col: t,
1094
+ "anomaly_score": float(sc),
1095
+ "is_anomaly": bool(fl),
1096
+ }
1097
+ )
1098
+
1099
+ if rows:
1100
+ data = pl.DataFrame(rows)
1101
+ else:
1102
+ data = pl.DataFrame(
966
1103
  {
967
- key: eid,
968
- year_col: yr,
969
- "anomaly_score": float(sc),
970
- "is_anomaly": bool(fl),
1104
+ key: pl.Series([], dtype=pl.String),
1105
+ output_time_col: pl.Series(
1106
+ [], dtype=pl.Date if date_col else pl.Int32
1107
+ ),
1108
+ "anomaly_score": pl.Series([], dtype=pl.Float64),
1109
+ "is_anomaly": pl.Series([], dtype=pl.Boolean),
971
1110
  }
972
1111
  )
973
1112
 
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
- )
985
1113
  global_base_vals = (
986
- df.filter(
987
- (pl.col(year_col) >= baseline[0])
988
- & (pl.col(year_col) <= baseline[1])
989
- )[column]
1114
+ df.filter(pl.col(year_col).is_between(baseline[0], baseline[1]))[column]
990
1115
  .drop_nulls()
991
1116
  .to_numpy()
992
1117
  .astype(float)
993
1118
  )
994
-
995
1119
  meta = {
996
1120
  "mode": "timeseries",
997
- "method": method.value if method is not None else None,
1121
+ "method": method.value,
998
1122
  "baseline": baseline,
1123
+ "target_years": target_years,
999
1124
  "baseline_mean": _sf(pl.Series(global_base_vals).mean())
1000
1125
  if len(global_base_vals) > 0
1001
1126
  else float("nan"),
1002
1127
  "baseline_fitted": True,
1128
+ "n_entities_dropped": n_dropped,
1003
1129
  }
1004
1130
 
1005
1131
  else:
File without changes
File without changes
File without changes
File without changes
File without changes