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.
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/PKG-INFO +1 -1
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/_version.py +2 -2
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/base/namespaces/stats.py +222 -120
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/.github/ISSUE_TEMPLATE/blank-proposal.yaml +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/.github/ISSUE_TEMPLATE/bug-report.yaml +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/.github/ISSUE_TEMPLATE/feature-request.yaml +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/.github/pull_request_template.md +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/.github/workflows/ci.yml +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/.github/workflows/gh-pages.yml +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/.github/workflows/pre-release.yml +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/.github/workflows/release.yml +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/.gitignore +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/.gitmessage +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/.pre-commit-config.yaml +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/.python-version +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/CONTRIBUTING.md +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/LICENSE +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/README.md +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/SKILLS.md +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/benchmarks/README.md +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/benchmarks/bench_aoi.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/benchmarks/bench_entity.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/benchmarks/bench_export.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/benchmarks/bench_polars_utils.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/benchmarks/bench_result.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/benchmarks/bench_schema.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/benchmarks/bench_season.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/benchmarks/bench_spatial.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/benchmarks/bench_view.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/benchmarks/run_all.sh +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/docs/Makefile +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/docs/make.bat +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/docs/source/concepts.md +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/docs/source/conf.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/docs/source/export.md +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/docs/source/index.rst +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/docs/source/intro.md +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/docs/source/logging.md +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/docs/source/plots.md +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/docs/source/plugins.md +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/docs/source/queries.md +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/docs/source/quickstart.md +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/docs/source/stats.md +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/examples/demo_mws.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/examples/demo_tehsil.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/hooks/mypy.sh +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/hooks/no-parquet-outside-fixtures.sh +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/hooks/pytest.sh +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/pyproject.toml +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/__init__.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/__main__.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/aoi.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/base/__init__.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/base/entity.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/base/namespaces/__init__.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/base/namespaces/plot.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/base/result.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/base/view.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/entities/__init__.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/entities/farms.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/entities/mws.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/entities/tehsil.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/entities/waterbody.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/export/__init__.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/export/formats.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/py.typed +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/schema/__init__.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/schema/detection.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/schema/profile.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/utils/__init__.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/utils/paths.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/utils/polars_utils.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/utils/season.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/src/core_lens/utils/spatial.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/tests/fixtures/generate_fixtures.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/tests/unit/conftest.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/tests/unit/test_aoi.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/tests/unit/test_entities.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/tests/unit/test_entity.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/tests/unit/test_export.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/tests/unit/test_main.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/tests/unit/test_plot.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/tests/unit/test_polars_utils.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/tests/unit/test_profile.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/tests/unit/test_result.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/tests/unit/test_schema_detection.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/tests/unit/test_schema_profile.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/tests/unit/test_season.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/tests/unit/test_season_config.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/tests/unit/test_spatial.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/tests/unit/test_stats.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/tests/unit/test_view.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev189}/usage.md +0 -0
- {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.
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 1, '
|
|
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):
|
|
758
|
-
baseline (tuple[int, int] | None, optional): ``(from_year, to_year)`` inclusive
|
|
759
|
-
timeseries; optional
|
|
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:
|
|
764
|
-
|
|
765
|
-
|
|
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
|
|
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
|
-
|
|
787
|
-
|
|
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
|
-
|
|
791
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 -
|
|
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
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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.
|
|
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
|
-
|
|
917
|
+
n_dropped = 0
|
|
897
918
|
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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
|
-
|
|
910
|
-
|
|
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
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
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
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
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
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
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:
|
|
968
|
-
year_col:
|
|
969
|
-
"anomaly_score":
|
|
970
|
-
"is_anomaly":
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|