robustkit 0.6.0__tar.gz → 0.7.0__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 (87) hide show
  1. {robustkit-0.6.0/robustkit.egg-info → robustkit-0.7.0}/PKG-INFO +536 -14
  2. {robustkit-0.6.0 → robustkit-0.7.0}/README.md +527 -13
  3. {robustkit-0.6.0 → robustkit-0.7.0}/pyproject.toml +11 -1
  4. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit/__init__.py +20 -2
  5. robustkit-0.7.0/robustkit/benchmark/global_model.py +422 -0
  6. robustkit-0.7.0/robustkit/core/pointwise.py +161 -0
  7. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit/core/uncertainty.py +44 -9
  8. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit/quantiles/reconstruct.py +47 -1
  9. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit/report/visualize_huber_iqr.py +57 -11
  10. robustkit-0.7.0/robustkit/segment_awareness/reports.py +1511 -0
  11. {robustkit-0.6.0 → robustkit-0.7.0/robustkit.egg-info}/PKG-INFO +536 -14
  12. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit.egg-info/SOURCES.txt +15 -0
  13. robustkit-0.7.0/tests/test_benchmark_curve_reference.py +149 -0
  14. robustkit-0.7.0/tests/test_benchmark_goodness_of_fit.py +114 -0
  15. robustkit-0.7.0/tests/test_benchmark_screening_report.py +143 -0
  16. robustkit-0.7.0/tests/test_dual_reference_outlier.py +120 -0
  17. {robustkit-0.6.0 → robustkit-0.7.0}/tests/test_export_outlier_pdf.py +37 -0
  18. {robustkit-0.6.0 → robustkit-0.7.0}/tests/test_huber_iqr.py +117 -0
  19. robustkit-0.7.0/tests/test_huber_iqr_pdf_and_title_fn.py +196 -0
  20. robustkit-0.7.0/tests/test_jackknife_cap_performance.py +137 -0
  21. robustkit-0.7.0/tests/test_plot_segment_vs_benchmark.py +115 -0
  22. robustkit-0.7.0/tests/test_pointwise_stability.py +116 -0
  23. robustkit-0.7.0/tests/test_segment_contribution.py +72 -0
  24. robustkit-0.7.0/tests/test_tier1_drilldown_symmetry.py +251 -0
  25. robustkit-0.7.0/tests/test_tier1_outlier_pdf_fixes.py +197 -0
  26. robustkit-0.7.0/tests/test_tier1_reference_population.py +78 -0
  27. robustkit-0.7.0/tests/test_tier2_local_mode_and_quality.py +131 -0
  28. robustkit-0.7.0/tests/test_tier2_reference_parameter.py +182 -0
  29. robustkit-0.6.0/robustkit/benchmark/global_model.py +0 -162
  30. robustkit-0.6.0/robustkit/segment_awareness/reports.py +0 -470
  31. {robustkit-0.6.0 → robustkit-0.7.0}/LICENSE +0 -0
  32. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit/benchmark/__init__.py +0 -0
  33. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit/benchmark/reporting.py +0 -0
  34. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit/benchmark/robustness_map.py +0 -0
  35. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit/common/__init__.py +0 -0
  36. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit/common/quadrants.py +0 -0
  37. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit/core/__init__.py +0 -0
  38. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit/core/consistency.py +0 -0
  39. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit/core/diagnostics.py +0 -0
  40. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit/core/goodness_of_fit.py +0 -0
  41. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit/core/stability.py +0 -0
  42. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit/core/trend.py +0 -0
  43. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit/information/__init__.py +0 -0
  44. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit/information/communication.py +0 -0
  45. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit/information/conditional_mi.py +0 -0
  46. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit/information/entropy.py +0 -0
  47. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit/information/mutual_info.py +0 -0
  48. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit/information/pairs.py +0 -0
  49. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit/information/profile.py +0 -0
  50. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit/information/quadrants.py +0 -0
  51. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit/information/utils.py +0 -0
  52. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit/information/visualization.py +0 -0
  53. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit/quantiles/__init__.py +0 -0
  54. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit/quantiles/io.py +0 -0
  55. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit/quantiles/trend.py +0 -0
  56. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit/report/__init__.py +0 -0
  57. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit/report/dispersion.py +0 -0
  58. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit/report/visualize_analyst.py +0 -0
  59. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit/report/visualize_publisher.py +0 -0
  60. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit/segment_awareness/__init__.py +0 -0
  61. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit/segmentation/__init__.py +0 -0
  62. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit/segmentation/apply.py +0 -0
  63. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit/segmentation/hierarchy.py +0 -0
  64. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit.egg-info/dependency_links.txt +0 -0
  65. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit.egg-info/requires.txt +0 -0
  66. {robustkit-0.6.0 → robustkit-0.7.0}/robustkit.egg-info/top_level.txt +0 -0
  67. {robustkit-0.6.0 → robustkit-0.7.0}/setup.cfg +0 -0
  68. {robustkit-0.6.0 → robustkit-0.7.0}/tests/test_benchmark.py +0 -0
  69. {robustkit-0.6.0 → robustkit-0.7.0}/tests/test_benchmark_gaps.py +0 -0
  70. {robustkit-0.6.0 → robustkit-0.7.0}/tests/test_benchmark_reporting.py +0 -0
  71. {robustkit-0.6.0 → robustkit-0.7.0}/tests/test_bugfixes_faseA.py +0 -0
  72. {robustkit-0.6.0 → robustkit-0.7.0}/tests/test_common_quadrants.py +0 -0
  73. {robustkit-0.6.0 → robustkit-0.7.0}/tests/test_core.py +0 -0
  74. {robustkit-0.6.0 → robustkit-0.7.0}/tests/test_export_no_deps.py +0 -0
  75. {robustkit-0.6.0 → robustkit-0.7.0}/tests/test_information.py +0 -0
  76. {robustkit-0.6.0 → robustkit-0.7.0}/tests/test_information_pairs.py +0 -0
  77. {robustkit-0.6.0 → robustkit-0.7.0}/tests/test_mad_outlier_report.py +0 -0
  78. {robustkit-0.6.0 → robustkit-0.7.0}/tests/test_n_boot_auto_propagation.py +0 -0
  79. {robustkit-0.6.0 → robustkit-0.7.0}/tests/test_quantiles_io_trend.py +0 -0
  80. {robustkit-0.6.0 → robustkit-0.7.0}/tests/test_quantiles_reconstruct.py +0 -0
  81. {robustkit-0.6.0 → robustkit-0.7.0}/tests/test_quantiles_reconstruct_mean_only.py +0 -0
  82. {robustkit-0.6.0 → robustkit-0.7.0}/tests/test_report.py +0 -0
  83. {robustkit-0.6.0 → robustkit-0.7.0}/tests/test_segment_awareness.py +0 -0
  84. {robustkit-0.6.0 → robustkit-0.7.0}/tests/test_segment_consistency.py +0 -0
  85. {robustkit-0.6.0 → robustkit-0.7.0}/tests/test_segment_drilldown.py +0 -0
  86. {robustkit-0.6.0 → robustkit-0.7.0}/tests/test_segmentation.py +0 -0
  87. {robustkit-0.6.0 → robustkit-0.7.0}/tests/test_trend_extras.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: robustkit
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: Practical tools for robust analysis of a single continuous relationship: trend fitting, stability checks, influence diagnostics, and bootstrap uncertainty.
5
5
  Author: Mikael Lundqvist
6
6
  License: MIT License
@@ -25,6 +25,14 @@ License: MIT License
25
25
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
26
  SOFTWARE.
27
27
 
28
+ Classifier: Development Status :: 4 - Beta
29
+ Classifier: Intended Audience :: Science/Research
30
+ Classifier: License :: OSI Approved :: MIT License
31
+ Classifier: Programming Language :: Python :: 3
32
+ Classifier: Programming Language :: Python :: 3.10
33
+ Classifier: Programming Language :: Python :: 3.11
34
+ Classifier: Programming Language :: Python :: 3.12
35
+ Classifier: Topic :: Scientific/Engineering
28
36
  Requires-Python: >=3.10
29
37
  Description-Content-Type: text/markdown
30
38
  License-File: LICENSE
@@ -42,10 +50,6 @@ Dynamic: license-file
42
50
 
43
51
  # robustkit
44
52
 
45
- > ⚠️ **Under active development.** This is an early placeholder release
46
- > to claim the package name on PyPI. The API is incomplete and may
47
- > change without notice. Not yet recommended for production use.
48
-
49
53
  Practical tools for robust analysis of a single continuous relationship:
50
54
  y as a function of one continuous x.
51
55
 
@@ -72,6 +76,14 @@ individual-level data from aggregated summaries), and
72
76
  analysis, no manual hierarchy construction required) are stable and
73
77
  tested.
74
78
 
79
+ **What is new in 0.7.0:** see `CHANGELOG.md`. In short: a `reference`
80
+ parameter for the outlier reports (`"model"`, `"curve"`, `"benchmark_curve"`),
81
+ dual-reference outlier classification, benchmark screening and
82
+ goodness-of-fit, pointwise stability, per-segment Huber+IQR export,
83
+ several drilldown and quality reports, and `jackknife_cap` for faster
84
+ bootstrap intervals on large segments. Calls written for 0.6.0 keep
85
+ working.
86
+
75
87
  **Recent fixes from real-dataset validation:**
76
88
  - `rank_features`/`quadrant_report`/`rank_communicative_pairs` no
77
89
  longer crash on pandas `Categorical` columns containing missing
@@ -131,6 +143,31 @@ ci = bca_bootstrap_ci(x, y, statistic_fn=lambda x_, y_: np.median(y_))
131
143
 
132
144
  See `examples/quickstart_tutorial.py` for a complete, runnable walkthrough.
133
145
 
146
+ ## Where along x is the trend weak?
147
+
148
+ `model_stability_pct` summarises the disagreement between Huber, Tukey and
149
+ OLS over the whole x range, and `bootstrap_band` gives the sampling
150
+ uncertainty of the Huber curve. `pointwise_stability_report` puts the two
151
+ side by side at every grid point, together with the number of observations
152
+ near that point, so that a thin region (say, few young employees) can be told
153
+ apart from a region where the fitting method matters:
154
+
155
+ ```python
156
+ from robustkit import pointwise_stability_report, plot_pointwise_stability
157
+
158
+ report = pointwise_stability_report(x, y, n_boot=300)
159
+ report[["x", "method_spread_pct", "band_width_pct", "local_n", "dominant"]]
160
+ # dominant == "sampling": the bootstrap band is wider than the methods' spread
161
+ # dominant == "method": the methods differ by more than the band
162
+
163
+ plot_pointwise_stability(x, y, report=report) # curves, band and a rug of observations
164
+ ```
165
+
166
+ `window_frac` (default 0.1) sets the width of the window used for `local_n`,
167
+ as a fraction of the x range. The three curves, the band and `pct_spread` are
168
+ the same numbers `model_stability_pct` and `bootstrap_band` return; nothing new
169
+ is fitted. Requires statsmodels (Tukey), like `model_stability_pct`.
170
+
134
171
  ## Trend growth rate and goodness of fit
135
172
 
136
173
  ```python
@@ -262,6 +299,28 @@ report = segment_position_report(
262
299
  `segment_position_report` never inspects what the model uses
263
300
  internally -- it only calls `predict()`.
264
301
 
302
+ **A pitfall worth knowing about if your custom model's `predict()`
303
+ uses `pd.get_dummies()`:** `segment_position_report` and
304
+ `plot_segment_vs_benchmark` both call `predict()` on ONE segment at a
305
+ time -- data that, by construction, has only a single value in each
306
+ of its defining categorical columns. Calling `pd.get_dummies()` fresh
307
+ on data like that generates a dummy column only for categories
308
+ actually present in that call, which for a single, fully-specified
309
+ segment is often none at all; a `reindex(columns=..., fill_value=0)`
310
+ step then silently fills in `0` where the correct value was `1`,
311
+ corrupting every per-segment prediction without raising an error.
312
+ Verified directly: an under-specified benchmark's segment-level
313
+ finding came out with the wrong sign and a wildly different magnitude
314
+ until this was fixed. The fix is to fit a `sklearn.OneHotEncoder` (or
315
+ equivalent) ONCE during training and reuse that SAME fitted encoder
316
+ inside `predict()`, rather than re-deriving dummy columns from
317
+ whatever data `predict()` happens to receive -- a fitted encoder
318
+ always knows the full set of training-time categories, regardless of
319
+ how homogeneous a later single call's data is. (`mad_outlier_report`
320
+ and `dual_reference_outlier_report` are NOT affected by this, since
321
+ both call `predict()` once on the full, heterogeneous population
322
+ rather than per segment.)
323
+
265
324
  **Small segments:** groups with fewer than `MIN_POINTS_FOR_CI` (default
266
325
  20) observations still get `observed_median` / `expected_median` /
267
326
  `difference`, but `ci_lower` / `ci_upper` are `NaN` and
@@ -271,7 +330,129 @@ small, since it can fail outright or become statistically meaningless.
271
330
  For segments at or above the threshold, the interval is a full BCa
272
331
  (bias-corrected and accelerated) bootstrap interval, via the same
273
332
  `bca_bootstrap_ci_by_index` primitive used elsewhere in the package --
274
- not a plain percentile bootstrap.
333
+ not a plain percentile bootstrap. That primitive's jackknife step
334
+ (needed for the acceleration term) is inherently O(n) regardless of
335
+ `n_boot`, which becomes the dominant cost on large real segments --
336
+ verified directly on a ~185,000-row segment (minutes, unusable) --
337
+ so above `jackknife_cap` observations (1000 by default), a random
338
+ subsample of that many positions is left out one at a time instead of
339
+ every single one. Confidence interval bounds from the capped and
340
+ uncapped versions were verified to differ by roughly 0.000005 on that
341
+ same real segment, while cutting its runtime to about 3 seconds; pass
342
+ `jackknife_cap=None` to any function built on
343
+ `bca_bootstrap_ci_by_index` (including `segment_contribution_report`
344
+ and `segment_benchmark_drilldown_report`) to always use the full,
345
+ uncapped jackknife instead.
346
+
347
+ `plot_segment_vs_benchmark` is the visual counterpart to
348
+ `segment_position_report`: a single summary difference (e.g. "ITS is
349
+ $988 below benchmark") can arise from several different underlying
350
+ patterns that look identical in a table -- the whole segment sitting
351
+ a bit below throughout, only its younger members sitting far below,
352
+ only its older members, or two opposing effects that happen to cancel
353
+ out in the median. This plots, for one or more segments, the actual
354
+ observations, each segment's own OBSERVED trend (via LOWESS -- a
355
+ flexible, assumption-free local smoother, deliberately not a
356
+ Huber/Tukey/OLS fit at a fixed degree, since the point here is a
357
+ segment's raw shape without imposing one), and the benchmark's
358
+ expected trend for that segment, letting you see exactly where
359
+ across `x_col` a segment's divergence actually occurs:
360
+
361
+ ```python
362
+ from robustkit import plot_segment_vs_benchmark
363
+
364
+ plot_segment_vs_benchmark(
365
+ df, segment_col="JobFamily", segment_values=["ENG", "ITS"],
366
+ y_col="salary", x_col="age", benchmark_fit=model_c,
367
+ )
368
+ ```
369
+
370
+ Works with a custom, multivariate `benchmark_fit` (any
371
+ `predict(dataframe)` object) the same way `segment_position_report`
372
+ does -- other columns the model needs are held constant per segment
373
+ (at that segment's own value) when building the benchmark curve, so a
374
+ segment like `"ENG_P2_Yes"` gets its benchmark line computed at
375
+ `P_niva="P2", OT="Yes"` throughout, not averaged across values that
376
+ don't apply to it. Requires `statsmodels` (imported lazily, same as
377
+ `fit_tukey_trend`) -- this is a diagnostic plot only, not a source of
378
+ numbers other functions build on, which is why LOWESS is used here
379
+ specifically rather than elsewhere in the package.
380
+
381
+ `benchmark_goodness_of_fit` evaluates an ALREADY-FITTED benchmark's
382
+ overall explanatory power -- r_squared, rmse, mae -- against the
383
+ actual values in y_col:
384
+
385
+ ```python
386
+ from robustkit import benchmark_goodness_of_fit
387
+
388
+ benchmark_goodness_of_fit(df, y_col="salary", benchmark_fit=model_c, x_col="age")
389
+ # {"r_squared": 0.997, "rmse": 895, "mae": 712}
390
+ ```
391
+
392
+ This is a different question from screening, below: even a screen
393
+ that finds nothing (every candidate's residual mutual information
394
+ near zero) says nothing about how much the benchmark explains
395
+ overall -- a benchmark can be "complete", in the sense that no
396
+ obvious structure is left to add, while still explaining relatively
397
+ little of `y_col`'s variance if the underlying noise is simply large.
398
+ Unlike `core.goodness_of_fit.goodness_of_fit`, which fits its own
399
+ trend internally and therefore only evaluates a single-column
400
+ robustkit trend, this function evaluates a benchmark that's already
401
+ been fit elsewhere -- a robustkit trend-fit dict, or any custom,
402
+ potentially multivariate `predict(dataframe)` object -- and never
403
+ re-fits anything itself. Verified directly: an intentionally
404
+ under-specified (age-only, linear) benchmark scored r_squared=0.874;
405
+ adding the true quadratic term and the two missing categorical
406
+ drivers raised it to r_squared=0.997 on the same data.
407
+
408
+ `benchmark_screening_report` screens a fitted benchmark for likely-missing
409
+ structure -- answering "is this benchmark good enough, or is there
410
+ something it's still missing?" -- without ever building or fitting a
411
+ new benchmark model automatically. Its `result["fit"]` entry is exactly
412
+ `benchmark_goodness_of_fit`'s output for the same benchmark, so the two
413
+ questions -- "how much does it explain overall" and "what specific
414
+ structure might it still be missing" -- are always available side by
415
+ side, numerically identical to calling either function directly:
416
+
417
+ ```python
418
+ from robustkit import benchmark_screening_report
419
+
420
+ result = benchmark_screening_report(
421
+ df, y_col="salary", benchmark_fit=model_c, x_col="age",
422
+ candidate_cols=["JobFamily", "level"],
423
+ )
424
+ result["fit"] # how much does the benchmark explain overall?
425
+ result["polynomial"] # does x_col's current degree capture its shape?
426
+ result["interactions"] # is there missing categorical structure or an interaction?
427
+ ```
428
+
429
+ Two genuinely different questions, deliberately answered with two
430
+ different tools rather than one unified ranking, because mutual
431
+ information cannot distinguish them. `result["polynomial"]` re-runs
432
+ `compare_polynomial_degrees` (Chapter 5) on the raw `(x_col, y_col)`
433
+ relationship directly -- checking polynomial degree this way, rather
434
+ than via mutual information, matters because MI is invariant to
435
+ monotonic reparametrization: on a domain where `x_col` is always
436
+ positive, `x_col`, `x_col**2`, and `x_col**3` are all monotonic
437
+ transforms of each other, so MI-based screening would report them as
438
+ carrying nearly identical information regardless of which degree
439
+ actually fits -- verified directly, within about 2% of each other on
440
+ a deliberately under-fit benchmark that clearly needed degree 2, not
441
+ 1. `result["interactions"]` instead fits the benchmark's own residual
442
+ as the target of `rank_features` -- reliable here specifically because
443
+ a genuinely missing categorical variable, or a combination of two
444
+ (`"JobFamily x level"`, generated automatically for every pairwise
445
+ combination of `candidate_cols` unless `interaction_pairs` is given
446
+ explicitly), is not a reparametrization of anything already in the
447
+ model, so MI detects it directly. Sorted by `information_efficiency`,
448
+ not raw `mutual_information` -- verified directly: an interaction term
449
+ can carry MORE raw mutual information than a simpler candidate while
450
+ still ranking lower on efficiency, the same high-cardinality trap
451
+ Chapter 8's `zip_code` example demonstrated. Every row is a candidate
452
+ worth investigating further, not a confirmed missing term -- automatic
453
+ interaction generation grows combinatorially and carries real
454
+ multiple-testing risk, especially with many candidate columns or a
455
+ modest sample size.
275
456
 
276
457
  ## Reporting: residuals, individual deviations, batch runs, and Excel export
277
458
 
@@ -425,6 +606,26 @@ convenience wrappers -- identical results to building the hierarchy
425
606
  by hand with `hierarchical_segment` and calling `apply_by_segment` /
426
607
  `segment_position_report` directly.
427
608
 
609
+ `segment_quality_report` combines several independent robustness
610
+ measures into a single row per segment -- model stability, Cook's
611
+ impact, MdAPE, and IQR of residuals -- so one table shows how
612
+ trustworthy each segment's conclusion is, rather than cross-
613
+ referencing several separate function calls by hand:
614
+
615
+ ```python
616
+ from robustkit import segment_quality_report
617
+
618
+ segment_quality_report(
619
+ df, x_col="age", y_col="salary", segment_cols=["JobFamily", "Level", "OT"], min_size=20,
620
+ )
621
+ # segment n skipped median_pct_diff max_pct_diff n_flagged cook_impact_pct mdape iqr_resid segment_level
622
+ ```
623
+
624
+ Each measure is computed and error-handled independently -- if
625
+ model stability fails (e.g. Tukey needs statsmodels, which may not be
626
+ installed), Cook's impact, MdAPE, and IQR are still reported for that
627
+ segment rather than the whole row failing.
628
+
428
629
  `mad_outlier_report` flags individuals whose residual is an outlier
429
630
  relative to their OWN segment's typical spread (MAD), not the whole
430
631
  population -- built on the same automatic hierarchical segmentation as
@@ -450,6 +651,26 @@ Segments with zero MAD (a degenerate case, usually a tiny segment
450
651
  where every residual happens to match) are treated as having an
451
652
  infinite threshold rather than flagging everyone in them.
452
653
 
654
+ `reference`: `"model"` (default) flags against `benchmark_fit` (or, if
655
+ none is given, a single Huber trend fit once across the whole
656
+ population). `"curve"` instead fits a fresh Huber trend WITHIN each
657
+ segment, on that segment's own data alone, and flags against THAT --
658
+ no benchmark model involved at all. Use `"curve"` when the flagging
659
+ itself, not just a chart, should match "how does this person compare
660
+ to their immediate segment-mates" -- e.g. a report for someone who
661
+ shouldn't need the underlying benchmark model explained to trust the
662
+ numbers. `"benchmark_curve"` is a third option in between: like
663
+ `"model"`, it starts from `benchmark_fit`'s raw predictions, but then,
664
+ WITHIN each segment, fits a Huber trend to those predictions (not to
665
+ the actual y values, as `"curve"` does) and flags against that smoothed
666
+ curve -- the exact reference `export_outlier_pdf`'s `mode="benchmark"`
667
+ draws, so pairing the two gives full consistency without discarding
668
+ the benchmark model's other covariates the way `"curve"` does.
669
+ `x_col` is required for all three, but `"curve"` and `"benchmark_curve"`
670
+ need it even when a custom `benchmark_fit` would otherwise have made it
671
+ optional. `mad_outlier_drilldown_report` takes the same `reference`
672
+ parameter, with identical semantics.
673
+
453
674
  `export_outlier_pdf` renders one chart per segment -- built from the
454
675
  same segmentation and flagging as `mad_outlier_report` -- as a
455
676
  one-page-per-segment PDF, for visual verification alongside the
@@ -461,12 +682,53 @@ from robustkit import export_outlier_pdf
461
682
  export_outlier_pdf(
462
683
  df, y_col="salary", segment_cols=["JobFamily", "Level", "OT"], x_col="age",
463
684
  path="outliers.pdf", min_size=20, k=3.0, id_cols=["employee_id"],
685
+ annotate_flagged_with="employee_id", # optional: label flagged points directly
464
686
  )
465
687
  ```
466
688
 
467
- Each page plots every observation in that segment, the benchmark's
468
- expected values (the exact same values used for flagging, not a
469
- separately re-fit curve), and flagged outliers marked distinctly.
689
+ `mode="benchmark"` (default) plots a Huber curve fit to the benchmark's
690
+ own predicted values, not the raw values connected point-to-point --
691
+ for a multivariate benchmark model (depending on more than `x_col`),
692
+ raw expected values aren't a pure function of `x_col` within a
693
+ segment, so connecting them can zig-zag sharply even for a perfectly
694
+ sensible model. The Huber-fit curve smooths over that without becoming
695
+ a different reference: it's still derived entirely from the
696
+ benchmark's own predictions. `mode="local"` instead fits a fresh Huber
697
+ trend within each segment on the actual observed values alone -- often
698
+ easier to read for "where does this person sit relative to their
699
+ immediate colleagues?"
700
+
701
+ `mode` (what's drawn) and `reference` (what flagging actually uses)
702
+ are independent choices -- `export_outlier_pdf` takes the same
703
+ `reference="model"/"curve"/"benchmark_curve"` parameter as
704
+ `mad_outlier_report`, and always computes flagging exactly the way
705
+ that report would with matching arguments, so the chart and the
706
+ numeric report never disagree. Pairing `mode="local"` with
707
+ `reference="curve"`, or `mode="benchmark"` with
708
+ `reference="benchmark_curve"`, gives a chart where the drawn curve and
709
+ the flagging rule are the *same thing* -- no benchmark model to
710
+ explain at all in the first case, or the full benchmark model's
711
+ covariates preserved (just smoothed for display) in the second --
712
+ useful for charts handed to someone who should just be able to look
713
+ and trust it. Other combinations -- e.g. `mode="benchmark"` with
714
+ `reference="curve"` -- are allowed too (draw the benchmark's smoothed
715
+ curve for context, but flag against each segment's own local trend);
716
+ the info box always states plainly which curve is drawn and which
717
+ reference the flagging rule actually used, so no combination is
718
+ ambiguous on the page itself.
719
+
720
+ `segment_mode="exclusive"` (default) vs. `segment_mode="drilldown"`
721
+ offers the same choice as `export_huber_iqr_pdf`: one page per
722
+ individual's single most-specific segment, or a page for every
723
+ qualifying group at every hierarchy level independently (with the
724
+ expected overlap -- the same individual can appear on multiple pages).
725
+
726
+ Each page plots every observation in that segment, the reference
727
+ curve, flagged outliers marked distinctly, and an info box (n, flagged
728
+ count/percentage, the segment's MAD, the flagging rule, and which
729
+ curve/reference is in play) so a reviewer can see *why* points are
730
+ flagged directly from the chart, without cross-referencing the numeric
731
+ report.
470
732
  Segments with fewer than `min_points_to_plot` (default 5) observations
471
733
  are skipped in the PDF -- a chart with a handful of points isn't
472
734
  meaningfully verifiable -- but still appear in `mad_outlier_report`'s
@@ -491,7 +753,10 @@ and again in the broader `Level x OT` row), whenever both groupings
491
753
  independently meet `min_size`:
492
754
 
493
755
  ```python
494
- from robustkit import segment_benchmark_drilldown_report, mad_outlier_drilldown_report
756
+ from robustkit import (
757
+ segment_benchmark_drilldown_report, mad_outlier_drilldown_report,
758
+ segment_stability_drilldown_report, outlier_drilldown_summary,
759
+ )
495
760
 
496
761
  segment_benchmark_drilldown_report(
497
762
  df, y_col="salary", segment_cols=["JobFamily", "Level", "OT"], x_col="age", min_size=20,
@@ -499,6 +764,9 @@ segment_benchmark_drilldown_report(
499
764
  mad_outlier_drilldown_report(
500
765
  df, y_col="salary", segment_cols=["JobFamily", "Level", "OT"], x_col="age", k=3.0,
501
766
  )
767
+ segment_stability_drilldown_report(
768
+ df, x_col="age", y_col="salary", segment_cols=["JobFamily", "Level", "OT"], min_size=20,
769
+ )
502
770
  ```
503
771
 
504
772
  Both add a `segment_level` column, and the sum of `n` across rows will
@@ -507,6 +775,127 @@ overlap, not a bug. Use the exclusive functions when you need to route
507
775
  each individual to one home; use the drilldown functions when you want
508
776
  to see every level side by side.
509
777
 
778
+ **A conclusion that survives multiple reference populations is more
779
+ trustworthy than one that only holds under a single, narrow
780
+ definition of "expected".** `outlier_drilldown_summary` counts how
781
+ many hierarchy levels each individual was flagged on:
782
+
783
+ ```python
784
+ drilldown = mad_outlier_drilldown_report(
785
+ df, y_col="salary", segment_cols=["JobFamily", "Level", "OT"], x_col="age",
786
+ k=3.0, id_cols=["employee_id"],
787
+ )
788
+ outlier_drilldown_summary(drilldown, id_col="employee_id")
789
+ # employee_id outlier_levels levels
790
+ ```
791
+
792
+ An individual flagged at every level (finest grouping AND every
793
+ broader fallback) is a stronger candidate than one that only appears
794
+ as an outlier under one specific, narrow segmentation -- a Cook's-
795
+ distance-style robustness check, applied to the choice of reference
796
+ population rather than to individual data points.
797
+
798
+ ### Reference Population Sensitivity: a fourth robustness axis
799
+
800
+ `robustkit` already checks robustness against fitting method
801
+ (Huber/Tukey/OLS, `core`), individual observations (Cook's impact,
802
+ `core.diagnostics`), and segment granularity (the drilldown functions
803
+ above). `dual_reference_outlier_report` adds a fourth axis: robustness
804
+ against WHICH reference population an individual is compared to.
805
+
806
+ ```python
807
+ from robustkit import dual_reference_outlier_report
808
+
809
+ report = dual_reference_outlier_report(
810
+ df, y_col="salary", x_col="age", segment_cols=["JobFamily", "Level", "OT"],
811
+ min_size=20, k=3.0, id_cols=["employee_id"],
812
+ )
813
+ # employee_id ... local_flagged global_flagged reference_type
814
+ ```
815
+
816
+ Each individual's residual is computed against two different
817
+ references simultaneously:
818
+ - **local** -- their own segment's Huber trend, fit fresh on just that
819
+ segment. Answers "how does this person deviate from their immediate
820
+ colleagues?"
821
+ - **global** -- the benchmark model (default single-column Huber, or
822
+ a custom multi-column model). Answers "how does this person deviate
823
+ from the organization's expected structure?"
824
+
825
+ `reference_type` classifies each individual: **"A"** (outlier under
826
+ both -- the strongest candidates, low regardless of how "expected" is
827
+ defined), **"B"** (outlier locally only -- often a tightly-clustered
828
+ segment where a modest dip stands out among close peers but not
829
+ against the wider population), **"C"** (outlier globally only --
830
+ often a whole segment trending low while each individual is typical
831
+ within it), or **"D"** (neither -- the normal case).
832
+
833
+ **A subtlety that matters for what "global" actually means:** the
834
+ global reference's MAD/median is computed across the ENTIRE
835
+ population, not re-centered per segment. If it were centered per
836
+ segment, a segment uniformly shifted below the benchmark would have
837
+ that shift silently absorbed by the per-segment median subtraction --
838
+ nobody in it would ever register as a global outlier, no matter how
839
+ far the whole segment sits from the benchmark, since everyone would
840
+ share roughly the same "typical" residual for their segment. This was
841
+ found by testing, not designed in from the start: an earlier version
842
+ reused `mad_outlier_report`'s per-segment MAD for the "global" side
843
+ and it could never produce a genuine Type C, because per-segment
844
+ recentering makes "global" behave like a second "local" view. It's
845
+ also worth knowing that if segment-level shifts are large relative to
846
+ individual noise AND affect a large share of the population, the
847
+ population-wide MAD naturally inflates to accommodate that variation
848
+ -- so a single shifted segment competing against several
849
+ similarly-large ones may not clear the threshold either; Type C shows
850
+ up most clearly when one segment's shift is a real minority pattern
851
+ against an otherwise-homogeneous majority, not when most segments
852
+ already differ substantially from each other.
853
+
854
+ `dual_reference_outlier_drilldown_report` extends the same A/B/C/D
855
+ classification to every hierarchy level at once, without exclusive
856
+ assignment -- the drilldown counterpart, same overlap semantics as
857
+ the other drilldown functions:
858
+
859
+ ```python
860
+ from robustkit import dual_reference_outlier_drilldown_report
861
+
862
+ dual_reference_outlier_drilldown_report(
863
+ df, y_col="salary", x_col="age", segment_cols=["JobFamily", "Level", "OT"], k=3.0,
864
+ )
865
+ # ... segment segment_level reference_type
866
+ ```
867
+
868
+ The GLOBAL reference is still computed once across the whole
869
+ population (not per level) for the same reason as above; only the
870
+ LOCAL reference is refit fresh within each group at each level.
871
+
872
+ ### Where does the effect come from: segment_contribution_report
873
+
874
+ `segment_benchmark_report`/`-drilldown_report` answer "how far from
875
+ benchmark is this segment?" `segment_contribution_report` answers a
876
+ different question: **where in the hierarchy does that gap actually
877
+ arise?**
878
+
879
+ ```python
880
+ from robustkit import segment_contribution_report
881
+
882
+ segment_contribution_report(
883
+ df, y_col="salary", segment_cols=["JobFamily", "Level", "OT"], x_col="age", min_size=20,
884
+ )
885
+ # segment segment_level ... difference parent_segment contribution
886
+ ```
887
+
888
+ Each segment's `contribution` is `child_difference - parent_difference`
889
+ -- the part of its benchmark gap NOT already explained by its broader,
890
+ coarser parent. A JobFamily within a Level+OT combination might show a
891
+ large `difference` from benchmark simply because that whole Level+OT
892
+ combination runs high or low (a STRUCTURAL effect, already visible one
893
+ level up); `contribution` isolates whatever remains once that broader
894
+ pattern is subtracted out -- the LOCAL effect specific to this finer
895
+ grouping. Segments at the coarsest hierarchy level have no parent
896
+ within `segment_cols`, so `contribution` is `NaN` and `parent_segment`
897
+ is `None` for them.
898
+
510
899
  ## Combined model + spread view
511
900
 
512
901
  `plot_analyst_view` and `plot_publisher_view` each show one thing --
@@ -576,6 +965,89 @@ The absence of an error bar at a given age is itself information --
576
965
  it signals the sample at that exact value is too small to say
577
966
  anything about spread, not just a plotting simplification.
578
967
 
968
+ **Per-segment PDF export:** `export_huber_iqr_pdf` renders one
969
+ `plot_huber_iqr` chart per segment -- built from the same automatic
970
+ hierarchical segmentation as the rest of `segment_awareness` -- as a
971
+ one-page-per-segment PDF, suitable for sharing with the people each
972
+ chart describes:
973
+
974
+ ```python
975
+ from robustkit import export_huber_iqr_pdf
976
+
977
+ export_huber_iqr_pdf(
978
+ df, x_col="age", y_col="salary", segment_cols=["JobFamily", "Level", "OT"],
979
+ path="segments.pdf", min_size=20,
980
+ )
981
+ ```
982
+
983
+ Domain-specific segment naming (e.g. translating an internal code like
984
+ `"ENG_P3_Yes"` into a readable label such as "Engineers, level 3,
985
+ overtime-eligible") stays entirely outside the package via an optional
986
+ `title_fn(segment_name, info) -> str` callback, where `info` is
987
+ `{"n", "segment_level"}`:
988
+
989
+ ```python
990
+ def pretty_title(segment_code, info):
991
+ return f"{my_own_translation(segment_code)} (n={info['n']})"
992
+
993
+ export_huber_iqr_pdf(..., title_fn=pretty_title)
994
+ ```
995
+
996
+ `export_outlier_pdf` accepts the same `title_fn(segment_name, info)`
997
+ callback for symmetry, with `info` there being `{"n", "n_flagged",
998
+ "pct_flagged", "k", "segment_mad", "segment_level"}` -- useful if
999
+ outlier review PDFs also need domain-specific titles, even though for
1000
+ many teams that PDF stays internal and doesn't need one. Both default
1001
+ to a generic title when `title_fn` is omitted, so existing calls are
1002
+ unaffected.
1003
+
1004
+ **`mode="exclusive"` (default) vs. `mode="drilldown"`**, matching the
1005
+ same distinction as the report functions above: `"exclusive"` assigns
1006
+ each individual to exactly one, most-specific segment -- one page per
1007
+ final segment, no overlap. `"drilldown"` instead renders a page for
1008
+ EVERY qualifying group at EVERY hierarchy level independently -- e.g.
1009
+ a page for `"ENG_P3_Yes"` AND a separate page for the broader
1010
+ `"P3_Yes"`, which includes those same individuals alongside everyone
1011
+ else at that level. The same individual can appear on multiple pages
1012
+ in drilldown mode -- that's the expected signature of overlap, same as
1013
+ the drilldown reports:
1014
+
1015
+ ```python
1016
+ export_huber_iqr_pdf(..., mode="drilldown")
1017
+ ```
1018
+
1019
+ All of `plot_huber_iqr`'s style parameters (`methods`, `show_points`,
1020
+ `show_bootstrap_band`, `cap_style`, `residual_box_metric`, `ylim`,
1021
+ `style`, `show_undersized_points`, ...) pass straight through `export_huber_iqr_pdf` to each
1022
+ segment's page -- including `show_points`, which switches between
1023
+ "publisher" charts (the default, `show_points=False`, no raw scatter)
1024
+ and "analyst" charts (`show_points=True`, individual observations
1025
+ visible) without needing to fall back to a manual per-segment loop.
1026
+
1027
+ **`export_huber_iqr_images`** is the same function in every respect
1028
+ except where the output goes: one image file per segment in a
1029
+ directory, instead of one combined PDF. Same segment_cols, same
1030
+ `mode`, same `title_fn`, same style parameters -- swap the function
1031
+ name and `path` for `output_dir` to get individual files instead of
1032
+ pages:
1033
+
1034
+ ```python
1035
+ from robustkit import export_huber_iqr_images
1036
+
1037
+ export_huber_iqr_images(
1038
+ df, x_col="age", y_col="salary", segment_cols=["JobFamily", "Level", "OT"],
1039
+ output_dir="salary_plots", mode="drilldown",
1040
+ )
1041
+ ```
1042
+
1043
+ Useful whenever per-segment charts get consumed individually --
1044
+ dropped into slide decks one at a time, browsed in a file explorer,
1045
+ or fed into some other pipeline expecting separate image files --
1046
+ rather than as a single document. Filenames are derived from each
1047
+ segment's id or label, sanitized for the filesystem; a numeric suffix
1048
+ is appended if two segments would otherwise collide. Returns the list
1049
+ of saved file paths, in the order rendered.
1050
+
579
1051
  ## Loading published quantile tables (SCB / JSON-stat)
580
1052
 
581
1053
  Some statistics agencies (e.g. Statistics Sweden, SCB) publish
@@ -674,16 +1146,19 @@ from robustkit import (
674
1146
  )
675
1147
 
676
1148
  # Method 1: repeat the mean n times -- zero within-group spread.
677
- # Recovers between-group regression coefficients reasonably well
1149
+ # Equivalent to weighting each group by n (see "What the flat method
1150
+ # actually is", below); recovers between-group regression coefficients
678
1151
  # (validated in the original technique this is based on) but
679
1152
  # understates individual-level variation.
680
1153
  flat = expand_aggregated_table_flat(df, n_col="n", mean_col="mean_salary", group_cols=["age"])
681
1154
 
682
1155
  # Method 2: borrow a dispersion_ratio from a DIFFERENT table that does
683
1156
  # report quantiles, and use it to imply an approximate spread around
684
- # the mean. Stacks two assumptions (mean-as-median, and that the
685
- # borrowed ratio transfers to this population) -- illustrative, not a
686
- # substitute for genuine quantile data for this specific table.
1157
+ # the given value, which becomes the reconstruction's MEDIAN (see
1158
+ # "What the borrowed-dispersion method does with `mean_col`", below).
1159
+ # Stacks two assumptions (mean-as-median, and that the borrowed ratio
1160
+ # transfers to this population) -- illustrative, not a substitute for
1161
+ # genuine quantile data for this specific table.
687
1162
  borrowed = expand_aggregated_table_borrowed_dispersion(
688
1163
  df, n_col="n", mean_col="mean_salary", dispersion_ratio=0.45, group_cols=["age"],
689
1164
  )
@@ -692,6 +1167,53 @@ borrowed = expand_aggregated_table_borrowed_dispersion(
692
1167
  compare_reconstruction_methods(n=2000, mean=52200, dispersion_ratio=0.45)
693
1168
  ```
694
1169
 
1170
+ **What the flat method actually is.** Repeating a group's number *n*
1171
+ times is exactly the same as weighting that group by *n*. For an
1172
+ ordinary least-squares fit whose regressors are constant within each
1173
+ group (an age band, say), a fit on the reconstructed rows is identical
1174
+ to a fit on the group values weighted by *n* -- and therefore to a fit
1175
+ on the original individuals; in a synthetic check, a slope of 1.948
1176
+ against 1.950. For a Huber fit it matches a Huber fit on the group
1177
+ values weighted by *n* (2.029 against 2.029). robustkit's `fit_*_trend`
1178
+ functions take no weights, so this is how to obtain *n*-weighting here.
1179
+ Treat it as a workaround rather than a feature: it adds no information
1180
+ beyond the group values and sizes. Two practical notes:
1181
+
1182
+ - It answers questions about whatever the table's number is. A table of
1183
+ means supports questions about the mean; a table of medians,
1184
+ questions about the typical value. Asking the other question gives
1185
+ large errors -- in the synthetic check, +75% (a means table asked
1186
+ about the typical value) and -37% (a medians table asked about the
1187
+ mean). The `mean_col` argument accepts a median if that is what the
1188
+ table gives.
1189
+ - Only the relative group sizes matter, so if the true *n* is very large
1190
+ (national tables can report millions per group), scale every *n* down
1191
+ by a common factor before reconstructing. Scaling by 0.1 changed a
1192
+ fitted slope only in the fourth decimal in the same check; scaling by
1193
+ 0.01, which rounds the smallest groups to a few rows, moved it by
1194
+ under half a percent. This applies to the flat method only -- in
1195
+ `expand_aggregated_table` and the borrowed-dispersion reconstruction
1196
+ the number of draws changes the tail, not just the weight.
1197
+
1198
+ **What the borrowed-dispersion method does with `mean_col`.** The
1199
+ reconstruction's *median* is set to that value, whatever the column is
1200
+ called. If the table publishes a median, pass it: the first assumption
1201
+ above (mean-as-median) then does not apply, and the reconstruction
1202
+ reproduces the median (a published 10.4 came back as 10.3 in a
1203
+ synthetic check). If the table publishes a mean, two things follow:
1204
+
1205
+ - The reconstruction does not reproduce the mean it was given. A
1206
+ lognormal's mean lies above its median, so the reconstructed mean is
1207
+ about `mean * exp(sigma**2 / 2)`, with
1208
+ `sigma = ln((1 + r/2) / (1 - r/2)) / (2 * 0.6745)` for a borrowed
1209
+ ratio `r`. For `r` = 0.4, 0.8 and 1.2 that is roughly 5%, 22% and 70%
1210
+ above the published mean (15.0 published, 18.3 reconstructed at
1211
+ `r` = 0.8 in the synthetic check).
1212
+ - How far a mean sits above its median depends on the spread, so the
1213
+ error this stand-in introduces differs between groups whose spread
1214
+ differs (in the same check the mean was 18% above the median in the
1215
+ youngest age band and 45% above it in the oldest).
1216
+
695
1217
  Both methods are documented with their specific assumptions rather
696
1218
  than presented as equally valid defaults -- being explicit about which
697
1219
  assumption was made lets the analyst judge how much a conclusion