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.
Files changed (94) hide show
  1. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/PKG-INFO +1 -1
  2. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/_version.py +2 -2
  3. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/base/namespaces/stats.py +398 -144
  4. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/uv.lock +3 -3
  5. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/.github/ISSUE_TEMPLATE/blank-proposal.yaml +0 -0
  6. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/.github/ISSUE_TEMPLATE/bug-report.yaml +0 -0
  7. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/.github/ISSUE_TEMPLATE/feature-request.yaml +0 -0
  8. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/.github/pull_request_template.md +0 -0
  9. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/.github/workflows/ci.yml +0 -0
  10. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/.github/workflows/gh-pages.yml +0 -0
  11. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/.github/workflows/pre-release.yml +0 -0
  12. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/.github/workflows/release.yml +0 -0
  13. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/.gitignore +0 -0
  14. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/.gitmessage +0 -0
  15. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/.pre-commit-config.yaml +0 -0
  16. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/.python-version +0 -0
  17. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/CONTRIBUTING.md +0 -0
  18. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/LICENSE +0 -0
  19. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/README.md +0 -0
  20. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/SKILLS.md +0 -0
  21. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/benchmarks/README.md +0 -0
  22. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/benchmarks/bench_aoi.py +0 -0
  23. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/benchmarks/bench_entity.py +0 -0
  24. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/benchmarks/bench_export.py +0 -0
  25. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/benchmarks/bench_polars_utils.py +0 -0
  26. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/benchmarks/bench_result.py +0 -0
  27. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/benchmarks/bench_schema.py +0 -0
  28. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/benchmarks/bench_season.py +0 -0
  29. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/benchmarks/bench_spatial.py +0 -0
  30. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/benchmarks/bench_view.py +0 -0
  31. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/benchmarks/run_all.sh +0 -0
  32. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/docs/Makefile +0 -0
  33. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/docs/make.bat +0 -0
  34. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/docs/source/concepts.md +0 -0
  35. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/docs/source/conf.py +0 -0
  36. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/docs/source/export.md +0 -0
  37. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/docs/source/index.rst +0 -0
  38. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/docs/source/intro.md +0 -0
  39. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/docs/source/logging.md +0 -0
  40. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/docs/source/plots.md +0 -0
  41. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/docs/source/plugins.md +0 -0
  42. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/docs/source/queries.md +0 -0
  43. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/docs/source/quickstart.md +0 -0
  44. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/docs/source/stats.md +0 -0
  45. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/examples/demo_mws.py +0 -0
  46. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/examples/demo_tehsil.py +0 -0
  47. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/hooks/mypy.sh +0 -0
  48. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/hooks/no-parquet-outside-fixtures.sh +0 -0
  49. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/hooks/pytest.sh +0 -0
  50. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/pyproject.toml +0 -0
  51. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/__init__.py +0 -0
  52. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/__main__.py +0 -0
  53. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/aoi.py +0 -0
  54. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/base/__init__.py +0 -0
  55. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/base/entity.py +0 -0
  56. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/base/namespaces/__init__.py +0 -0
  57. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/base/namespaces/plot.py +0 -0
  58. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/base/result.py +0 -0
  59. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/base/view.py +0 -0
  60. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/entities/__init__.py +0 -0
  61. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/entities/farms.py +0 -0
  62. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/entities/mws.py +0 -0
  63. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/entities/tehsil.py +0 -0
  64. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/entities/waterbody.py +0 -0
  65. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/export/__init__.py +0 -0
  66. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/export/formats.py +0 -0
  67. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/py.typed +0 -0
  68. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/schema/__init__.py +0 -0
  69. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/schema/detection.py +0 -0
  70. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/schema/profile.py +0 -0
  71. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/utils/__init__.py +0 -0
  72. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/utils/paths.py +0 -0
  73. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/utils/polars_utils.py +0 -0
  74. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/utils/season.py +0 -0
  75. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/src/core_lens/utils/spatial.py +0 -0
  76. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/tests/fixtures/generate_fixtures.py +0 -0
  77. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/tests/unit/conftest.py +0 -0
  78. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/tests/unit/test_aoi.py +0 -0
  79. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/tests/unit/test_entities.py +0 -0
  80. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/tests/unit/test_entity.py +0 -0
  81. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/tests/unit/test_export.py +0 -0
  82. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/tests/unit/test_main.py +0 -0
  83. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/tests/unit/test_plot.py +0 -0
  84. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/tests/unit/test_polars_utils.py +0 -0
  85. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/tests/unit/test_profile.py +0 -0
  86. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/tests/unit/test_result.py +0 -0
  87. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/tests/unit/test_schema_detection.py +0 -0
  88. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/tests/unit/test_schema_profile.py +0 -0
  89. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/tests/unit/test_season.py +0 -0
  90. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/tests/unit/test_season_config.py +0 -0
  91. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/tests/unit/test_spatial.py +0 -0
  92. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/tests/unit/test_stats.py +0 -0
  93. {core_lens-0.1.dev186 → core_lens-0.1.dev189}/tests/unit/test_view.py +0 -0
  94. {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.dev186
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.dev186'
22
- __version_tuple__ = version_tuple = (0, 1, 'dev186')
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: "Result") -> None:
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
- ) -> "Result":
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
- """Pairwise correlations between columns.
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 (list[str]): At least 2 column names.
246
- method (CorrelateMethod, optional): A :class:`~core_lens.base.namespaces.stats.CorrelateMethod` enum value.
247
- across (str, optional): ``"entity"`` or ``"time"`` — recorded in metadata only.
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 has columns
251
- ``column_a | column_b | correlation | p_value``.
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 2 columns supplied.
255
- ValueError: If ``method`` is not recognised.
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
- Under the hood:
258
- Calls ``scipy.stats.pearsonr``, ``scipy.stats.spearmanr``, or ``scipy.stats.kendalltau``
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. Valid options: {[e.name for e in 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
- df = self._r.df()
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: dict[str, Any] = {
300
- "method": method.value if method is not None else None,
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
- ) -> "Result":
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
- ) -> "Result":
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
- ) -> "Result":
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): An :class:`~core_lens.base.namespaces.stats.AnomalyCrossMethod` or :class:`~core_lens.base.namespaces.stats.AnomalyTsMethod` enum value.
606
- baseline (tuple[int, int] | None, optional): ``(from_year, to_year)`` inclusive. Required for
607
- timeseries; optional for cross-sectional.
758
+ method (AnomalyCrossMethod | AnomalyTsMethod): Enum value for detection method.
759
+ baseline (tuple[int, int] | None, optional): ``(from_year, to_year)`` inclusive,
760
+ used as the reference/"normal" period. Required for timeseries; optional
761
+ for cross-sectional.
762
+ target_years (tuple[int, int] | None, optional): ``(from_year, to_year)`` inclusive,
763
+ the years actually scored for anomalies. Timeseries: defaults to "everything
764
+ after ``baseline[1]``" if omitted (old behaviour preserved). Cross-sectional:
765
+ defaults to all rows if omitted. Must not overlap ``baseline``.
608
766
  threshold (float, optional): Sigma / score threshold for anomaly flag (default 2.0).
609
767
 
610
768
  Returns:
611
- Result: Result whose data has ``key_col | anomaly_score | is_anomaly``
612
- (cross-sectional) or ``key_col | year | anomaly_score | is_anomaly``
613
- (timeseries, baseline period excluded).
769
+ Result: data has ``key_col | anomaly_score | is_anomaly`` (cross-sectional) or
770
+ ``key_col | year | anomaly_score | is_anomaly`` (timeseries, baseline excluded,
771
+ restricted to ``target_years`` if given). ``metadata`` includes
772
+ ``n_entities_dropped`` (entities skipped for insufficient baseline obs).
614
773
 
615
774
  Raises:
616
- ValueError: If ``mode``, ``method``, or observation count invalid.
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
- # baseline subset for computing reference stats
635
- if baseline is not None and "year" in df.columns:
801
+ has_year = "year" in df.columns
802
+
803
+ if baseline is not None and has_year:
636
804
  ref_vals = (
637
- df.filter(
638
- (pl.col("year") >= baseline[0])
639
- & (pl.col("year") <= baseline[1])
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
- all_vals = df[column].to_numpy().astype(float)
822
+ # target_years scopes which rows get scored/returned; baseline stays reference-only.
823
+ eval_df = df
824
+ if target_years is not None and has_year:
825
+ eval_df = df.filter(
826
+ pl.col("year").is_between(target_years[0], target_years[1])
827
+ )
828
+ all_vals = eval_df[column].to_numpy().astype(float)
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
- med = _sf(_rs.median())
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 - med) / std
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
- data = df.select(pl.col(key)).with_columns(
720
- [
721
- pl.Series("anomaly_score", scores.tolist(), dtype=pl.Float64),
722
- pl.Series("is_anomaly", flags.tolist(), dtype=pl.Boolean),
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. Ensure data is at annual or sub_annual resolution."
912
+ "StatsNamespace.anomaly: mode 'timeseries' requires a year/time column. "
913
+ "Ensure data is at annual or sub_annual resolution."
741
914
  )
742
915
 
743
916
  min_obs = _MIN_OBS[method.value]
744
- rows: list[dict[str, Any]] = []
917
+ n_dropped = 0
745
918
 
746
- for eid in df[key].unique().sort().to_list():
747
- sub = df.filter(pl.col(key) == eid).sort(year_col)
748
- base_vals = (
749
- sub.filter(
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
- if len(base_vals) < min_obs:
758
- continue
924
+ counts = base_df.group_by(key).agg(
925
+ pl.col(column).drop_nulls().len().alias("_n")
926
+ )
927
+ valid_keys = counts.filter(pl.col("_n") >= min_obs).select(key)
928
+ n_dropped = counts.height - valid_keys.height
759
929
 
760
- eval_sub = sub.filter(pl.col(year_col) > baseline[1])
761
- eval_vals = eval_sub[column].drop_nulls().to_numpy().astype(float)
762
- eval_years = eval_sub[year_col].to_list()
763
-
764
- ts_scores: list[float] = []
765
- ts_flags: list[bool] = []
766
-
767
- if method is AnomalyTsMethod.MAD:
768
- _bs = pl.Series(base_vals)
769
- med = _sf(_bs.median())
770
- mad = _sf(pl.Series(np.abs(base_vals - med)).median())
771
- scale = (mad * 1.4826) or 1.0
772
- ts_scores = [(v - med) / scale for v in eval_vals]
773
- ts_flags = [abs(s) > threshold for s in ts_scores]
774
-
775
- elif method is AnomalyTsMethod.CUSUM:
776
- _bs = pl.Series(base_vals)
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
- full_vals = sub[column].to_numpy().astype(float)
795
- if len(full_vals) < min_obs:
796
- continue
797
- period = (
798
- 24 if len(full_vals) >= 24 else max(2, len(full_vals) // 2)
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
- res = STL(full_vals, period=period).fit()
801
- resid = res.resid
802
- base_len = len(base_vals)
803
- base_resid = resid[:base_len]
804
- eval_resid = resid[base_len : base_len + len(eval_years)]
805
- std = _sf(pl.Series(base_resid).std(ddof=1)) or 1.0
806
- ts_scores = [float(r / std) for r in eval_resid]
807
- ts_flags = [abs(s) > threshold for s in ts_scores]
808
- except Exception:
809
- ts_scores = [float("nan")] * len(eval_years)
810
- ts_flags = [False] * len(eval_years)
811
-
812
- for yr, sc, fl in zip(eval_years, ts_scores, ts_flags):
813
- rows.append(
993
+ else:
994
+ eval_sub = sub.filter(pl.col(year_col) > baseline[1])
995
+ eval_vals = eval_sub[column].drop_nulls().to_numpy().astype(float)
996
+ eval_years = eval_sub.filter(pl.col(column).is_not_null())[
997
+ year_col
998
+ ].to_list()
999
+
1000
+ ts_scores: list[float] = []
1001
+ ts_flags: list[bool] = []
1002
+
1003
+ if method is AnomalyTsMethod.CUSUM:
1004
+ mean = _sf(pl.Series(base_vals).mean())
1005
+ std = _sf(pl.Series(base_vals).std(ddof=1)) or 1.0
1006
+ k, h = 0.5 * std, threshold * std
1007
+ cp, cn = 0.0, 0.0
1008
+ for v in eval_vals:
1009
+ cp = max(0.0, cp + v - mean - k)
1010
+ cn = max(0.0, cn - v + mean - k)
1011
+ s = max(cp, cn)
1012
+ ts_scores.append(s)
1013
+ ts_flags.append(s > h)
1014
+
1015
+ else: # STL
1016
+ try:
1017
+ from statsmodels.tsa.seasonal import STL # type: ignore[import-untyped]
1018
+
1019
+ full = sub.filter(pl.col(column).is_not_null())
1020
+ full_vals = full[column].to_numpy().astype(float)
1021
+ full_years = full[year_col].to_list()
1022
+ if len(full_vals) < min_obs:
1023
+ n_dropped += 1
1024
+ continue
1025
+
1026
+ # Seasonal period: sub-annual cadence assumed 24 steps/year
1027
+ # (e.g. fortnightly). Falls back to half the series length
1028
+ # for shorter records. Adjust if your data's cadence differs.
1029
+ period = (
1030
+ 24
1031
+ if len(full_vals) >= 24
1032
+ else max(2, len(full_vals) // 2)
1033
+ )
1034
+ res = STL(full_vals, period=period).fit()
1035
+ resid = res.resid
1036
+
1037
+ # Match residuals to years EXPLICITLY (not positional slicing) —
1038
+ # positional slicing silently misaligns if there are gaps between
1039
+ # baseline and eval rows.
1040
+ resid_by_year = dict(zip(full_years, resid))
1041
+ base_resid = [
1042
+ resid_by_year[y]
1043
+ for y in full_years
1044
+ if baseline[0] <= y <= baseline[1]
1045
+ ]
1046
+ std = _sf(pl.Series(base_resid).std(ddof=1)) or 1.0
1047
+
1048
+ ts_scores = [
1049
+ float(resid_by_year[y] / std)
1050
+ if y in resid_by_year
1051
+ else float("nan")
1052
+ for y in eval_years
1053
+ ]
1054
+ ts_flags = [
1055
+ abs(s) > threshold if s == s else False
1056
+ for s in ts_scores
1057
+ ] # s==s filters NaN
1058
+
1059
+ except Exception as e:
1060
+ warnings.warn(
1061
+ f"StatsNamespace.anomaly: STL fit failed for entity {eid!r}: {e}"
1062
+ )
1063
+ ts_scores = [float("nan")] * len(eval_years)
1064
+ ts_flags = [False] * len(eval_years)
1065
+
1066
+ for yr, sc, fl in zip(eval_years, ts_scores, ts_flags):
1067
+ rows.append(
1068
+ {
1069
+ key: eid,
1070
+ year_col: yr,
1071
+ "anomaly_score": float(sc),
1072
+ "is_anomaly": bool(fl),
1073
+ }
1074
+ )
1075
+
1076
+ data = (
1077
+ pl.DataFrame(rows)
1078
+ if rows
1079
+ else pl.DataFrame(
814
1080
  {
815
- key: eid,
816
- year_col: yr,
817
- "anomaly_score": float(sc),
818
- "is_anomaly": bool(fl),
1081
+ key: pl.Series([], dtype=pl.String),
1082
+ year_col: pl.Series([], dtype=pl.Int32),
1083
+ "anomaly_score": pl.Series([], dtype=pl.Float64),
1084
+ "is_anomaly": pl.Series([], dtype=pl.Boolean),
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 if method is not None else None,
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
- ) -> "Result":
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.58"
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/26/d6/5f358ff283325580c2003a6d953aea18cfe10ae87b46f5ebc80fa3a386dc/gitpython-3.1.58.tar.gz", hash = "sha256:621416df10ef3fd0e19fabf9172ddeed0fa704d353d04f194eec56a625a95b22", size = 228498, upload-time = "2026-08-04T15:05:49.47Z" }
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/ec/0c/9d8752098bc442f0726e64aa6135940b3a96809915d1aa4206c1bb97881d/gitpython-3.1.58-py3-none-any.whl", hash = "sha256:d331e722577f0fd7fc1f857419b3ecc07af66282b933d2a4d95f84a042fdd50f", size = 220183, upload-time = "2026-08-04T15:05:48.025Z" },
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