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.
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/PKG-INFO +1 -1
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/_version.py +2 -2
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/base/namespaces/stats.py +244 -118
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/.github/ISSUE_TEMPLATE/blank-proposal.yaml +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/.github/ISSUE_TEMPLATE/bug-report.yaml +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/.github/ISSUE_TEMPLATE/feature-request.yaml +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/.github/pull_request_template.md +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/.github/workflows/ci.yml +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/.github/workflows/gh-pages.yml +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/.github/workflows/pre-release.yml +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/.github/workflows/release.yml +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/.gitignore +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/.gitmessage +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/.pre-commit-config.yaml +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/.python-version +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/CONTRIBUTING.md +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/LICENSE +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/README.md +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/SKILLS.md +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/benchmarks/README.md +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/benchmarks/bench_aoi.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/benchmarks/bench_entity.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/benchmarks/bench_export.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/benchmarks/bench_polars_utils.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/benchmarks/bench_result.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/benchmarks/bench_schema.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/benchmarks/bench_season.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/benchmarks/bench_spatial.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/benchmarks/bench_view.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/benchmarks/run_all.sh +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/docs/Makefile +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/docs/make.bat +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/docs/source/concepts.md +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/docs/source/conf.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/docs/source/export.md +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/docs/source/index.rst +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/docs/source/intro.md +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/docs/source/logging.md +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/docs/source/plots.md +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/docs/source/plugins.md +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/docs/source/queries.md +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/docs/source/quickstart.md +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/docs/source/stats.md +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/examples/demo_mws.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/examples/demo_tehsil.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/hooks/mypy.sh +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/hooks/no-parquet-outside-fixtures.sh +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/hooks/pytest.sh +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/pyproject.toml +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/__init__.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/__main__.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/aoi.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/base/__init__.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/base/entity.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/base/namespaces/__init__.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/base/namespaces/plot.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/base/result.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/base/view.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/entities/__init__.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/entities/farms.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/entities/mws.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/entities/tehsil.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/entities/waterbody.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/export/__init__.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/export/formats.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/py.typed +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/schema/__init__.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/schema/detection.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/schema/profile.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/utils/__init__.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/utils/paths.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/utils/polars_utils.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/utils/season.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/src/core_lens/utils/spatial.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/tests/fixtures/generate_fixtures.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/tests/unit/conftest.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/tests/unit/test_aoi.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/tests/unit/test_entities.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/tests/unit/test_entity.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/tests/unit/test_export.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/tests/unit/test_main.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/tests/unit/test_plot.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/tests/unit/test_polars_utils.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/tests/unit/test_profile.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/tests/unit/test_result.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/tests/unit/test_schema_detection.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/tests/unit/test_schema_profile.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/tests/unit/test_season.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/tests/unit/test_season_config.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/tests/unit/test_spatial.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/tests/unit/test_stats.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/tests/unit/test_view.py +0 -0
- {core_lens-0.1.dev188 → core_lens-0.1.dev190}/usage.md +0 -0
- {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.
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 1, '
|
|
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):
|
|
758
|
-
baseline (tuple[int, int] | None, optional): ``(from_year, to_year)`` inclusive
|
|
759
|
-
timeseries; optional
|
|
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:
|
|
764
|
-
|
|
765
|
-
|
|
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
|
|
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
|
-
|
|
787
|
-
|
|
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
|
-
|
|
791
|
-
|
|
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
|
-
|
|
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,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 =
|
|
872
|
-
|
|
873
|
-
|
|
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.
|
|
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
|
-
|
|
923
|
+
n_dropped = 0
|
|
897
924
|
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
)
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
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
|
-
|
|
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
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
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
|
-
|
|
1032
|
+
26 if len(full_vals) >= 52 else max(2, len(full_vals) // 2)
|
|
951
1033
|
)
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
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:
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
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
|
|
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
|
|
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
|