core-lens 0.1.dev186__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.dev186 → core_lens-0.1.dev189}/PKG-INFO +1 -1
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/_version.py +2 -2
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/base/namespaces/stats.py +398 -144
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/uv.lock +3 -3
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/.github/ISSUE_TEMPLATE/blank-proposal.yaml +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/.github/ISSUE_TEMPLATE/bug-report.yaml +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/.github/ISSUE_TEMPLATE/feature-request.yaml +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/.github/pull_request_template.md +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/.github/workflows/ci.yml +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/.github/workflows/gh-pages.yml +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/.github/workflows/pre-release.yml +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/.github/workflows/release.yml +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/.gitignore +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/.gitmessage +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/.pre-commit-config.yaml +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/.python-version +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/CONTRIBUTING.md +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/LICENSE +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/README.md +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/SKILLS.md +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/benchmarks/README.md +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/benchmarks/bench_aoi.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/benchmarks/bench_entity.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/benchmarks/bench_export.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/benchmarks/bench_polars_utils.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/benchmarks/bench_result.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/benchmarks/bench_schema.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/benchmarks/bench_season.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/benchmarks/bench_spatial.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/benchmarks/bench_view.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/benchmarks/run_all.sh +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/docs/Makefile +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/docs/make.bat +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/docs/source/concepts.md +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/docs/source/conf.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/docs/source/export.md +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/docs/source/index.rst +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/docs/source/intro.md +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/docs/source/logging.md +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/docs/source/plots.md +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/docs/source/plugins.md +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/docs/source/queries.md +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/docs/source/quickstart.md +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/docs/source/stats.md +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/examples/demo_mws.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/examples/demo_tehsil.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/hooks/mypy.sh +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/hooks/no-parquet-outside-fixtures.sh +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/hooks/pytest.sh +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/pyproject.toml +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/__init__.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/__main__.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/aoi.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/base/__init__.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/base/entity.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/base/namespaces/__init__.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/base/namespaces/plot.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/base/result.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/base/view.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/entities/__init__.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/entities/farms.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/entities/mws.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/entities/tehsil.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/entities/waterbody.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/export/__init__.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/export/formats.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/py.typed +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/schema/__init__.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/schema/detection.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/schema/profile.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/utils/__init__.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/utils/paths.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/utils/polars_utils.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/utils/season.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/utils/spatial.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/tests/fixtures/generate_fixtures.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/tests/unit/conftest.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/tests/unit/test_aoi.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/tests/unit/test_entities.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/tests/unit/test_entity.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/tests/unit/test_export.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/tests/unit/test_main.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/tests/unit/test_plot.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/tests/unit/test_polars_utils.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/tests/unit/test_profile.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/tests/unit/test_result.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/tests/unit/test_schema_detection.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/tests/unit/test_schema_profile.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/tests/unit/test_season.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/tests/unit/test_season_config.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/tests/unit/test_spatial.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/tests/unit/test_stats.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/tests/unit/test_view.py +0 -0
- {core_lens-0.1.dev186 → core_lens-0.1.dev189}/usage.md +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
|
|
@@ -11,6 +11,7 @@ import numpy as np
|
|
|
11
11
|
import polars as pl
|
|
12
12
|
|
|
13
13
|
from core_lens.utils.polars_utils import collect_lf, cached_read_schema
|
|
14
|
+
from core_lens.base.result import Result
|
|
14
15
|
|
|
15
16
|
if TYPE_CHECKING:
|
|
16
17
|
from core_lens.base.result import Result
|
|
@@ -157,7 +158,7 @@ class StatsNamespace:
|
|
|
157
158
|
Computed values always go in ``data``; method parameters go in ``metadata``.
|
|
158
159
|
"""
|
|
159
160
|
|
|
160
|
-
def __init__(self, result:
|
|
161
|
+
def __init__(self, result: Result) -> None:
|
|
161
162
|
"""Initialize StatsNamespace.
|
|
162
163
|
|
|
163
164
|
Args:
|
|
@@ -178,7 +179,7 @@ class StatsNamespace:
|
|
|
178
179
|
self,
|
|
179
180
|
columns: list[str] | None = None,
|
|
180
181
|
by: str = "column",
|
|
181
|
-
) ->
|
|
182
|
+
) -> Result:
|
|
182
183
|
r"""Per-column or per-entity descriptive statistics.
|
|
183
184
|
|
|
184
185
|
Uses polars' in-built methods for mean, std, min, max, quantiles etc.
|
|
@@ -238,26 +239,80 @@ class StatsNamespace:
|
|
|
238
239
|
columns: list[str],
|
|
239
240
|
method: CorrelateMethod = CorrelateMethod.PEARSON,
|
|
240
241
|
across: str = "entity",
|
|
242
|
+
group_by: str | None = None,
|
|
243
|
+
min_obs: int = 3,
|
|
241
244
|
) -> "Result":
|
|
242
|
-
"""
|
|
245
|
+
"""Compute pairwise correlations between columns.
|
|
246
|
+
|
|
247
|
+
Correlations are computed with ``scipy.stats`` and returned as a
|
|
248
|
+
:class:`~core_lens.base.result.Result` whose ``data`` is a Polars
|
|
249
|
+
``DataFrame``. Two modes are supported:
|
|
250
|
+
|
|
251
|
+
* **Pooled** (``group_by is None``): all rows are treated as one
|
|
252
|
+
observation set and a single correlation is computed per column pair.
|
|
253
|
+
* **Per-entity** (``group_by`` set): the result is grouped by the given
|
|
254
|
+
key column (e.g. ``"mws_id"``) and one correlation is computed per
|
|
255
|
+
entity across its remaining rows (typically its time series). This is
|
|
256
|
+
what enables questions such as "which MWS show the strongest
|
|
257
|
+
dependence of cropping intensity on annual rainfall?".
|
|
243
258
|
|
|
244
259
|
Args:
|
|
245
|
-
columns
|
|
246
|
-
|
|
247
|
-
|
|
260
|
+
columns: Column names to correlate. Must contain at least two
|
|
261
|
+
entries. In pooled mode every pair is computed; in per-entity
|
|
262
|
+
mode the first two columns are used as the pair.
|
|
263
|
+
method: The correlation coefficient to compute. One of
|
|
264
|
+
:class:`CorrelateMethod` (``PEARSON``, ``SPEARMAN``, ``KENDALL``).
|
|
265
|
+
across: ``"entity"`` or ``"time"``. Recorded in result metadata to
|
|
266
|
+
describe the intended axis of the relationship.
|
|
267
|
+
group_by: Optional key column to compute one correlation per group
|
|
268
|
+
(e.g. ``"mws_id"``). When ``None``, the pooled behaviour is
|
|
269
|
+
preserved for backward compatibility.
|
|
270
|
+
min_obs: Minimum number of non-null observations required per group
|
|
271
|
+
(per-entity mode) or overall (pooled mode) before a correlation
|
|
272
|
+
is computed. Groups below this threshold are skipped.
|
|
248
273
|
|
|
249
274
|
Returns:
|
|
250
|
-
Result: Result whose data
|
|
251
|
-
|
|
275
|
+
Result: A :class:`~core_lens.base.result.Result` whose ``data``
|
|
276
|
+
DataFrame has the following columns:
|
|
277
|
+
|
|
278
|
+
* Pooled mode: ``column_a | column_b | correlation | p_value``
|
|
279
|
+
* Per-entity mode: ``{group_by} | correlation | p_value | n_obs``
|
|
280
|
+
|
|
281
|
+
Geometry is dropped (``has_geometry=False``).
|
|
252
282
|
|
|
253
283
|
Raises:
|
|
254
|
-
CorrelationError: If fewer than
|
|
255
|
-
ValueError: If ``method`` is not
|
|
284
|
+
CorrelationError: If fewer than two columns are supplied.
|
|
285
|
+
ValueError: If ``method`` is not a valid :class:`CorrelateMethod`,
|
|
286
|
+
or if ``group_by`` is set but the column is not present in the
|
|
287
|
+
underlying data.
|
|
256
288
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
to compute the corresponding correlation coefficients and p-values.
|
|
289
|
+
Examples:
|
|
290
|
+
Pooled correlation across all MWS in the AoI::
|
|
260
291
|
|
|
292
|
+
result = aoi.mws.annual.stats.correlate(
|
|
293
|
+
columns=["dw_precipitation", "ci_cropping_intensity"],
|
|
294
|
+
method=CorrelateMethod.PEARSON,
|
|
295
|
+
)
|
|
296
|
+
print(result.data.collect())
|
|
297
|
+
|
|
298
|
+
Per-MWS correlation, ranked by strength of dependence::
|
|
299
|
+
|
|
300
|
+
top5 = (
|
|
301
|
+
aoi.mws.annual.stats
|
|
302
|
+
.correlate(
|
|
303
|
+
columns=["dw_precipitation", "ci_cropping_intensity"],
|
|
304
|
+
method=CorrelateMethod.PEARSON,
|
|
305
|
+
group_by="mws_id",
|
|
306
|
+
min_obs=3,
|
|
307
|
+
)
|
|
308
|
+
.data
|
|
309
|
+
.with_columns(pl.col("correlation").abs().alias("abs_corr"))
|
|
310
|
+
.sort("abs_corr", descending=True)
|
|
311
|
+
.select("mws_id", "correlation", "p_value", "n_obs")
|
|
312
|
+
.limit(5)
|
|
313
|
+
.collect()
|
|
314
|
+
)
|
|
315
|
+
print(top5)
|
|
261
316
|
"""
|
|
262
317
|
if len(columns) < 2:
|
|
263
318
|
raise CorrelationError(
|
|
@@ -265,27 +320,124 @@ class StatsNamespace:
|
|
|
265
320
|
)
|
|
266
321
|
if not isinstance(method, CorrelateMethod):
|
|
267
322
|
raise ValueError(
|
|
268
|
-
f"StatsNamespace.correlate: method must be a CorrelateMethod.
|
|
323
|
+
f"StatsNamespace.correlate: method must be a CorrelateMethod. "
|
|
324
|
+
f"Valid options: {[e.name for e in CorrelateMethod]}."
|
|
269
325
|
)
|
|
270
326
|
|
|
271
327
|
import scipy.stats as sp
|
|
272
328
|
|
|
273
|
-
|
|
329
|
+
lf = (
|
|
330
|
+
self._r.lazy()
|
|
331
|
+
) # LazyFrame — stays lazy until an explicit collect boundary below
|
|
332
|
+
|
|
333
|
+
def _t_pvals(corr_col: "pl.Expr", n_col: "pl.Expr") -> "pl.Expr":
|
|
334
|
+
r_safe = corr_col.clip(-0.9999999, 0.9999999)
|
|
335
|
+
return (corr_col * ((n_col - 2) / (1 - r_safe**2)).sqrt()).alias("t_stat")
|
|
336
|
+
|
|
337
|
+
# Per-entity mode
|
|
338
|
+
if group_by is not None:
|
|
339
|
+
# Schema check only — does NOT execute the query plan.
|
|
340
|
+
if group_by not in lf.collect_schema().names():
|
|
341
|
+
raise ValueError(
|
|
342
|
+
f"StatsNamespace.correlate: group_by column '{group_by}' "
|
|
343
|
+
f"not found in data. Available: {lf.collect_schema().names()}"
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
col_a, col_b = columns[0], columns[1]
|
|
347
|
+
|
|
348
|
+
if method in (CorrelateMethod.PEARSON, CorrelateMethod.SPEARMAN):
|
|
349
|
+
# Fully lazy path: rank (if Spearman) -> group_by -> agg -> filter.
|
|
350
|
+
# No collect until the very end, where scipy needs numpy anyway.
|
|
351
|
+
if method is CorrelateMethod.SPEARMAN:
|
|
352
|
+
src = lf.with_columns(
|
|
353
|
+
pl.col(col_a)
|
|
354
|
+
.rank(method="average")
|
|
355
|
+
.over(group_by)
|
|
356
|
+
.alias("__a"),
|
|
357
|
+
pl.col(col_b)
|
|
358
|
+
.rank(method="average")
|
|
359
|
+
.over(group_by)
|
|
360
|
+
.alias("__b"),
|
|
361
|
+
)
|
|
362
|
+
a_col, b_col = "__a", "__b"
|
|
363
|
+
else:
|
|
364
|
+
src, a_col, b_col = lf, col_a, col_b
|
|
365
|
+
|
|
366
|
+
lazy_result = (
|
|
367
|
+
src.group_by(group_by)
|
|
368
|
+
.agg(
|
|
369
|
+
pl.corr(pl.col(a_col), pl.col(b_col), method="pearson").alias(
|
|
370
|
+
"correlation"
|
|
371
|
+
),
|
|
372
|
+
pl.len().alias("n_obs"),
|
|
373
|
+
)
|
|
374
|
+
.filter(pl.col("correlation").is_finite())
|
|
375
|
+
.filter(pl.col("n_obs") >= min_obs)
|
|
376
|
+
.with_columns(_t_pvals(pl.col("correlation"), pl.col("n_obs")))
|
|
377
|
+
.filter(pl.col("t_stat").is_finite())
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
# --- single collect boundary: scipy.t.sf needs eager numpy ---
|
|
381
|
+
result = lazy_result.collect()
|
|
382
|
+
t_vals = result["t_stat"].to_numpy()
|
|
383
|
+
df_vals = result["n_obs"].to_numpy() - 2
|
|
384
|
+
p_vals = 2.0 * sp.t.sf(np.abs(t_vals), df_vals)
|
|
385
|
+
result = result.with_columns(pl.Series("p_value", p_vals))
|
|
386
|
+
|
|
387
|
+
data = result.select([group_by, "correlation", "p_value", "n_obs"])
|
|
388
|
+
|
|
389
|
+
else:
|
|
390
|
+
# Kendall: scipy has no batched/lazy equivalent, so this branch
|
|
391
|
+
# is inherently eager. Collect ONCE here — not scattered filters.
|
|
392
|
+
df = lf.collect()
|
|
393
|
+
rows: list[dict[str, Any]] = []
|
|
394
|
+
for sub_full in df.partition_by(group_by):
|
|
395
|
+
key = sub_full[group_by][0]
|
|
396
|
+
sub = sub_full.select([col_a, col_b]).drop_nulls()
|
|
397
|
+
a = sub[col_a].to_numpy().astype(float)
|
|
398
|
+
b = sub[col_b].to_numpy().astype(float)
|
|
399
|
+
n = len(a)
|
|
400
|
+
if n < min_obs or a.std() == 0 or b.std() == 0:
|
|
401
|
+
continue
|
|
402
|
+
corr, pval = sp.kendalltau(a, b)
|
|
403
|
+
rows.append(
|
|
404
|
+
{
|
|
405
|
+
group_by: key,
|
|
406
|
+
"correlation": float(cast(float, corr)),
|
|
407
|
+
"p_value": float(cast(float, pval)),
|
|
408
|
+
"n_obs": n,
|
|
409
|
+
}
|
|
410
|
+
)
|
|
411
|
+
data = pl.DataFrame(rows)
|
|
412
|
+
|
|
413
|
+
metadata: dict[str, Any] = {
|
|
414
|
+
"method": method.value,
|
|
415
|
+
"columns": [col_a, col_b],
|
|
416
|
+
"across": across,
|
|
417
|
+
"group_by": group_by,
|
|
418
|
+
"min_obs": min_obs,
|
|
419
|
+
"n_entities_computed": len(data),
|
|
420
|
+
}
|
|
421
|
+
return self._r._replace(data=data, has_geometry=False, metadata=metadata)
|
|
422
|
+
|
|
423
|
+
# Pooled mode — eager needed regardless (scipy.pearsonr/spearmanr/
|
|
424
|
+
# kendalltau all take raw numpy arrays). Collect ONCE here.
|
|
425
|
+
df = lf.collect()
|
|
274
426
|
n_obs = len(df)
|
|
275
|
-
rows: list[dict[str, Any]] = []
|
|
276
427
|
|
|
428
|
+
rows = []
|
|
277
429
|
for col_a, col_b in combinations(columns, 2):
|
|
278
430
|
sub = df.select([col_a, col_b]).drop_nulls()
|
|
279
431
|
a = sub[col_a].to_numpy().astype(float)
|
|
280
432
|
b = sub[col_b].to_numpy().astype(float)
|
|
281
|
-
|
|
433
|
+
if len(a) < min_obs or a.std() == 0 or b.std() == 0:
|
|
434
|
+
continue
|
|
282
435
|
if method is CorrelateMethod.PEARSON:
|
|
283
436
|
corr, pval = sp.pearsonr(a, b)
|
|
284
437
|
elif method is CorrelateMethod.SPEARMAN:
|
|
285
438
|
corr, pval = sp.spearmanr(a, b)
|
|
286
439
|
else:
|
|
287
440
|
corr, pval = sp.kendalltau(a, b)
|
|
288
|
-
|
|
289
441
|
rows.append(
|
|
290
442
|
{
|
|
291
443
|
"column_a": col_a,
|
|
@@ -296,8 +448,8 @@ class StatsNamespace:
|
|
|
296
448
|
)
|
|
297
449
|
|
|
298
450
|
data = pl.DataFrame(rows)
|
|
299
|
-
metadata
|
|
300
|
-
"method": method.value
|
|
451
|
+
metadata = {
|
|
452
|
+
"method": method.value,
|
|
301
453
|
"columns": columns,
|
|
302
454
|
"across": across,
|
|
303
455
|
"n_observations": n_obs,
|
|
@@ -312,7 +464,7 @@ class StatsNamespace:
|
|
|
312
464
|
against: float | None = None,
|
|
313
465
|
method: TestMethod | None = None,
|
|
314
466
|
significance_level: float = 0.05,
|
|
315
|
-
) ->
|
|
467
|
+
) -> Result:
|
|
316
468
|
"""Hypothesis test in three modes: group-based, period-based, single-sample.
|
|
317
469
|
|
|
318
470
|
Args:
|
|
@@ -470,7 +622,7 @@ class StatsNamespace:
|
|
|
470
622
|
from_period: int,
|
|
471
623
|
to_period: int,
|
|
472
624
|
method: ChangeMethod = ChangeMethod.ABSOLUTE,
|
|
473
|
-
) ->
|
|
625
|
+
) -> Result:
|
|
474
626
|
"""Change between two time periods per entity.
|
|
475
627
|
|
|
476
628
|
Args:
|
|
@@ -595,33 +747,48 @@ class StatsNamespace:
|
|
|
595
747
|
mode: str,
|
|
596
748
|
method: AnomalyCrossMethod | AnomalyTsMethod,
|
|
597
749
|
baseline: tuple[int, int] | None = None,
|
|
750
|
+
target_years: tuple[int, int] | None = None,
|
|
598
751
|
threshold: float = 2.0,
|
|
599
|
-
) ->
|
|
752
|
+
) -> Result:
|
|
600
753
|
"""Anomaly detection in cross-sectional or timeseries mode.
|
|
601
754
|
|
|
602
755
|
Args:
|
|
603
756
|
column (str): Value column to analyse.
|
|
604
757
|
mode (str): ``"cross_sectional"`` or ``"timeseries"``.
|
|
605
|
-
method (AnomalyCrossMethod | AnomalyTsMethod):
|
|
606
|
-
baseline (tuple[int, int] | None, optional): ``(from_year, to_year)`` inclusive
|
|
607
|
-
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``.
|
|
608
766
|
threshold (float, optional): Sigma / score threshold for anomaly flag (default 2.0).
|
|
609
767
|
|
|
610
768
|
Returns:
|
|
611
|
-
Result:
|
|
612
|
-
|
|
613
|
-
|
|
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).
|
|
614
773
|
|
|
615
774
|
Raises:
|
|
616
|
-
ValueError: If ``mode
|
|
617
|
-
|
|
618
|
-
Under the hood:
|
|
619
|
-
- Most anomaly methods (ZSCORE, IQR, PERCENTILE, THRESHOLD, MAD, CUSUM) are implemented
|
|
620
|
-
using native Polars aggregations for high performance.
|
|
621
|
-
- The STL method calls ``statsmodels.tsa.seasonal.STL`` to decompose timeseries data.
|
|
622
|
-
|
|
775
|
+
ValueError: If ``mode``/``method`` invalid, observation count too low, or
|
|
776
|
+
``baseline`` and ``target_years`` overlap.
|
|
623
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).
|
|
624
789
|
df = self._r.df()
|
|
790
|
+
if isinstance(df, pl.LazyFrame):
|
|
791
|
+
df = df.collect()
|
|
625
792
|
key = self._r.key_cols[0]
|
|
626
793
|
|
|
627
794
|
if mode == "cross_sectional":
|
|
@@ -631,13 +798,13 @@ class StatsNamespace:
|
|
|
631
798
|
f"Valid options: {[e.name for e in AnomalyCrossMethod]}."
|
|
632
799
|
)
|
|
633
800
|
|
|
634
|
-
|
|
635
|
-
|
|
801
|
+
has_year = "year" in df.columns
|
|
802
|
+
|
|
803
|
+
if baseline is not None and has_year:
|
|
636
804
|
ref_vals = (
|
|
637
|
-
df.filter(
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
)[column]
|
|
805
|
+
df.filter(pl.col("year").is_between(baseline[0], baseline[1]))[
|
|
806
|
+
column
|
|
807
|
+
]
|
|
641
808
|
.drop_nulls()
|
|
642
809
|
.to_numpy()
|
|
643
810
|
.astype(float)
|
|
@@ -652,7 +819,13 @@ class StatsNamespace:
|
|
|
652
819
|
f"but only got {len(ref_vals)}."
|
|
653
820
|
)
|
|
654
821
|
|
|
655
|
-
|
|
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)
|
|
656
829
|
|
|
657
830
|
if method is AnomalyCrossMethod.ZSCORE:
|
|
658
831
|
_rs = pl.Series(ref_vals)
|
|
@@ -664,23 +837,23 @@ class StatsNamespace:
|
|
|
664
837
|
"mode": "cross_sectional",
|
|
665
838
|
"method": "zscore",
|
|
666
839
|
"baseline": baseline,
|
|
840
|
+
"target_years": target_years,
|
|
667
841
|
"baseline_mean": mean,
|
|
668
842
|
"baseline_std": std,
|
|
669
843
|
}
|
|
670
844
|
|
|
671
845
|
elif method is AnomalyCrossMethod.IQR:
|
|
672
846
|
_rs = pl.Series(ref_vals)
|
|
673
|
-
q1 = _sf(_rs.quantile(0.25))
|
|
674
|
-
q3 = _sf(_rs.quantile(0.75))
|
|
847
|
+
q1, q3 = _sf(_rs.quantile(0.25)), _sf(_rs.quantile(0.75))
|
|
675
848
|
iqr = q3 - q1
|
|
676
849
|
lo, hi = q1 - 1.5 * iqr, q3 + 1.5 * iqr
|
|
677
|
-
|
|
678
|
-
scores = (all_vals - med) / (iqr or 1.0)
|
|
850
|
+
scores = (all_vals - _sf(_rs.median())) / (iqr or 1.0)
|
|
679
851
|
flags = (all_vals < lo) | (all_vals > hi)
|
|
680
852
|
meta = {
|
|
681
853
|
"mode": "cross_sectional",
|
|
682
854
|
"method": "iqr",
|
|
683
855
|
"baseline": baseline,
|
|
856
|
+
"target_years": target_years,
|
|
684
857
|
"baseline_mean": _sf(_rs.mean()),
|
|
685
858
|
"q1": q1,
|
|
686
859
|
"q3": q3,
|
|
@@ -689,16 +862,15 @@ class StatsNamespace:
|
|
|
689
862
|
|
|
690
863
|
elif method is AnomalyCrossMethod.PERCENTILE:
|
|
691
864
|
_rs = pl.Series(ref_vals)
|
|
692
|
-
lo = _sf(_rs.quantile(0.05))
|
|
693
|
-
hi = _sf(_rs.quantile(0.95))
|
|
694
|
-
med = _sf(_rs.median())
|
|
865
|
+
lo, hi = _sf(_rs.quantile(0.05)), _sf(_rs.quantile(0.95))
|
|
695
866
|
std = _sf(_rs.std()) or 1.0
|
|
696
|
-
scores = (all_vals -
|
|
867
|
+
scores = (all_vals - _sf(_rs.median())) / std
|
|
697
868
|
flags = (all_vals < lo) | (all_vals > hi)
|
|
698
869
|
meta = {
|
|
699
870
|
"mode": "cross_sectional",
|
|
700
871
|
"method": "percentile",
|
|
701
872
|
"baseline": baseline,
|
|
873
|
+
"target_years": target_years,
|
|
702
874
|
"baseline_mean": _sf(_rs.mean()),
|
|
703
875
|
"lower_pct": lo,
|
|
704
876
|
"upper_pct": hi,
|
|
@@ -712,15 +884,15 @@ class StatsNamespace:
|
|
|
712
884
|
"mode": "cross_sectional",
|
|
713
885
|
"method": "threshold",
|
|
714
886
|
"baseline": baseline,
|
|
887
|
+
"target_years": target_years,
|
|
715
888
|
"baseline_mean": mean,
|
|
716
889
|
"threshold": threshold,
|
|
717
890
|
}
|
|
718
891
|
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
]
|
|
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),
|
|
724
896
|
)
|
|
725
897
|
|
|
726
898
|
elif mode == "timeseries":
|
|
@@ -737,117 +909,199 @@ class StatsNamespace:
|
|
|
737
909
|
year_col = self._year_col()
|
|
738
910
|
if year_col is None:
|
|
739
911
|
raise ValueError(
|
|
740
|
-
"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."
|
|
741
914
|
)
|
|
742
915
|
|
|
743
916
|
min_obs = _MIN_OBS[method.value]
|
|
744
|
-
|
|
917
|
+
n_dropped = 0
|
|
745
918
|
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
(pl.col(year_col) >= baseline[0])
|
|
751
|
-
& (pl.col(year_col) <= baseline[1])
|
|
752
|
-
)[column]
|
|
753
|
-
.drop_nulls()
|
|
754
|
-
.to_numpy()
|
|
755
|
-
.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])
|
|
756
923
|
)
|
|
757
|
-
|
|
758
|
-
|
|
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
|
|
759
929
|
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
mean = _sf(_bs.mean())
|
|
778
|
-
std = _sf(_bs.std(ddof=1)) or 1.0
|
|
779
|
-
k, h = 0.5 * std, threshold * std
|
|
780
|
-
cp, cn = 0.0, 0.0
|
|
781
|
-
for v in eval_vals:
|
|
782
|
-
cp = max(0.0, cp + v - mean - k)
|
|
783
|
-
cn = max(0.0, cn - v + mean - k)
|
|
784
|
-
s = max(cp, cn)
|
|
785
|
-
ts_scores.append(s)
|
|
786
|
-
ts_flags.append(s > h)
|
|
787
|
-
|
|
788
|
-
else: # stl
|
|
789
|
-
try:
|
|
790
|
-
from statsmodels.tsa.seasonal import ( # type: ignore[import-untyped]
|
|
791
|
-
STL,
|
|
792
|
-
)
|
|
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
|
+
)
|
|
793
947
|
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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
|
+
)
|
|
955
|
+
|
|
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
|
+
)
|
|
799
992
|
)
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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(
|
|
814
1080
|
{
|
|
815
|
-
key:
|
|
816
|
-
year_col:
|
|
817
|
-
"anomaly_score":
|
|
818
|
-
"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),
|
|
819
1085
|
}
|
|
820
1086
|
)
|
|
821
|
-
|
|
822
|
-
if rows:
|
|
823
|
-
data = pl.DataFrame(rows)
|
|
824
|
-
else:
|
|
825
|
-
data = pl.DataFrame(
|
|
826
|
-
{
|
|
827
|
-
key: pl.Series([], dtype=pl.String),
|
|
828
|
-
year_col: pl.Series([], dtype=pl.Int32),
|
|
829
|
-
"anomaly_score": pl.Series([], dtype=pl.Float64),
|
|
830
|
-
"is_anomaly": pl.Series([], dtype=pl.Boolean),
|
|
831
|
-
}
|
|
832
1087
|
)
|
|
1088
|
+
|
|
833
1089
|
global_base_vals = (
|
|
834
|
-
df.filter(
|
|
835
|
-
(pl.col(year_col) >= baseline[0])
|
|
836
|
-
& (pl.col(year_col) <= baseline[1])
|
|
837
|
-
)[column]
|
|
1090
|
+
df.filter(pl.col(year_col).is_between(baseline[0], baseline[1]))[column]
|
|
838
1091
|
.drop_nulls()
|
|
839
1092
|
.to_numpy()
|
|
840
1093
|
.astype(float)
|
|
841
1094
|
)
|
|
842
|
-
|
|
843
1095
|
meta = {
|
|
844
1096
|
"mode": "timeseries",
|
|
845
|
-
"method": method.value
|
|
1097
|
+
"method": method.value,
|
|
846
1098
|
"baseline": baseline,
|
|
1099
|
+
"target_years": target_years,
|
|
847
1100
|
"baseline_mean": _sf(pl.Series(global_base_vals).mean())
|
|
848
1101
|
if len(global_base_vals) > 0
|
|
849
1102
|
else float("nan"),
|
|
850
1103
|
"baseline_fitted": True,
|
|
1104
|
+
"n_entities_dropped": n_dropped,
|
|
851
1105
|
}
|
|
852
1106
|
|
|
853
1107
|
else:
|
|
@@ -863,7 +1117,7 @@ class StatsNamespace:
|
|
|
863
1117
|
columns: dict[str, Any],
|
|
864
1118
|
method: SimilarityMethod = SimilarityMethod.EUCLIDEAN,
|
|
865
1119
|
top_n: int = 10,
|
|
866
|
-
) ->
|
|
1120
|
+
) -> Result:
|
|
867
1121
|
"""Find entities most similar to ``target`` across ``columns``.
|
|
868
1122
|
|
|
869
1123
|
Args:
|
|
@@ -1409,14 +1409,14 @@ wheels = [
|
|
|
1409
1409
|
|
|
1410
1410
|
[[package]]
|
|
1411
1411
|
name = "gitpython"
|
|
1412
|
-
version = "3.1.
|
|
1412
|
+
version = "3.1.62"
|
|
1413
1413
|
source = { registry = "https://pypi.org/simple" }
|
|
1414
1414
|
dependencies = [
|
|
1415
1415
|
{ name = "gitdb" },
|
|
1416
1416
|
]
|
|
1417
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
1417
|
+
sdist = { url = "https://files.pythonhosted.org/packages/e0/db/3ca813cbacb23ab6fe46ff38a9b5ef8e73e970c8051f2ce903aacafe0446/gitpython-3.1.62.tar.gz", hash = "sha256:1791de66309bc0c7cfca40bf8d2e3de7ca091cbf94e6051be1ad0722c61062af", size = 231728, upload-time = "2026-09-07T02:57:21.155Z" }
|
|
1418
1418
|
wheels = [
|
|
1419
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
1419
|
+
{ url = "https://files.pythonhosted.org/packages/d6/0b/29d7965215f8ef830a7ca1f42997fe13e5693d85e9edb18f938d063ef5f2/gitpython-3.1.62-py3-none-any.whl", hash = "sha256:7002251225e10e29d2e1f49e6532613fe5d5d9f0b6f1f02997a52b38fe56899e", size = 222753, upload-time = "2026-09-07T02:57:19.762Z" },
|
|
1420
1420
|
]
|
|
1421
1421
|
|
|
1422
1422
|
[[package]]
|
|
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
|