robustkit 0.5.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 (91) hide show
  1. robustkit-0.7.0/PKG-INFO +1306 -0
  2. robustkit-0.7.0/README.md +1256 -0
  3. {robustkit-0.5.0 → robustkit-0.7.0}/pyproject.toml +12 -1
  4. {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/__init__.py +46 -6
  5. robustkit-0.7.0/robustkit/benchmark/global_model.py +422 -0
  6. robustkit-0.7.0/robustkit/benchmark/reporting.py +317 -0
  7. {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/benchmark/robustness_map.py +1 -1
  8. robustkit-0.7.0/robustkit/core/consistency.py +90 -0
  9. robustkit-0.7.0/robustkit/core/pointwise.py +161 -0
  10. robustkit-0.7.0/robustkit/core/uncertainty.py +206 -0
  11. {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/information/mutual_info.py +13 -1
  12. {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/information/visualization.py +1 -1
  13. {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/quantiles/io.py +1 -1
  14. {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/quantiles/reconstruct.py +47 -1
  15. {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/quantiles/trend.py +3 -2
  16. {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/report/visualize_analyst.py +7 -2
  17. robustkit-0.7.0/robustkit/report/visualize_huber_iqr.py +359 -0
  18. {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/report/visualize_publisher.py +3 -2
  19. robustkit-0.7.0/robustkit/segment_awareness/__init__.py +17 -0
  20. robustkit-0.7.0/robustkit/segment_awareness/reports.py +1511 -0
  21. robustkit-0.7.0/robustkit.egg-info/PKG-INFO +1306 -0
  22. {robustkit-0.5.0 → robustkit-0.7.0}/robustkit.egg-info/SOURCES.txt +30 -0
  23. {robustkit-0.5.0 → robustkit-0.7.0}/robustkit.egg-info/requires.txt +3 -0
  24. robustkit-0.7.0/tests/test_benchmark_curve_reference.py +149 -0
  25. robustkit-0.7.0/tests/test_benchmark_gaps.py +159 -0
  26. robustkit-0.7.0/tests/test_benchmark_goodness_of_fit.py +114 -0
  27. robustkit-0.7.0/tests/test_benchmark_reporting.py +195 -0
  28. robustkit-0.7.0/tests/test_benchmark_screening_report.py +143 -0
  29. robustkit-0.7.0/tests/test_bugfixes_faseA.py +102 -0
  30. robustkit-0.7.0/tests/test_dual_reference_outlier.py +120 -0
  31. robustkit-0.7.0/tests/test_export_no_deps.py +119 -0
  32. robustkit-0.7.0/tests/test_export_outlier_pdf.py +153 -0
  33. robustkit-0.7.0/tests/test_huber_iqr.py +314 -0
  34. robustkit-0.7.0/tests/test_huber_iqr_pdf_and_title_fn.py +196 -0
  35. robustkit-0.7.0/tests/test_jackknife_cap_performance.py +137 -0
  36. robustkit-0.7.0/tests/test_mad_outlier_report.py +117 -0
  37. robustkit-0.7.0/tests/test_n_boot_auto_propagation.py +86 -0
  38. robustkit-0.7.0/tests/test_plot_segment_vs_benchmark.py +115 -0
  39. robustkit-0.7.0/tests/test_pointwise_stability.py +116 -0
  40. {robustkit-0.5.0 → robustkit-0.7.0}/tests/test_quantiles_io_trend.py +17 -9
  41. {robustkit-0.5.0 → robustkit-0.7.0}/tests/test_report.py +20 -0
  42. robustkit-0.7.0/tests/test_segment_awareness.py +124 -0
  43. robustkit-0.7.0/tests/test_segment_consistency.py +72 -0
  44. robustkit-0.7.0/tests/test_segment_contribution.py +72 -0
  45. robustkit-0.7.0/tests/test_segment_drilldown.py +84 -0
  46. robustkit-0.7.0/tests/test_tier1_drilldown_symmetry.py +251 -0
  47. robustkit-0.7.0/tests/test_tier1_outlier_pdf_fixes.py +197 -0
  48. robustkit-0.7.0/tests/test_tier1_reference_population.py +78 -0
  49. robustkit-0.7.0/tests/test_tier2_local_mode_and_quality.py +131 -0
  50. robustkit-0.7.0/tests/test_tier2_reference_parameter.py +182 -0
  51. robustkit-0.5.0/PKG-INFO +0 -446
  52. robustkit-0.5.0/README.md +0 -406
  53. robustkit-0.5.0/robustkit/benchmark/global_model.py +0 -74
  54. robustkit-0.5.0/robustkit/core/consistency.py +0 -33
  55. robustkit-0.5.0/robustkit/core/uncertainty.py +0 -83
  56. robustkit-0.5.0/robustkit.egg-info/PKG-INFO +0 -446
  57. {robustkit-0.5.0 → robustkit-0.7.0}/LICENSE +0 -0
  58. {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/benchmark/__init__.py +0 -0
  59. {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/common/__init__.py +0 -0
  60. {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/common/quadrants.py +0 -0
  61. {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/core/__init__.py +0 -0
  62. {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/core/diagnostics.py +0 -0
  63. {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/core/goodness_of_fit.py +0 -0
  64. {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/core/stability.py +0 -0
  65. {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/core/trend.py +0 -0
  66. {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/information/__init__.py +0 -0
  67. {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/information/communication.py +0 -0
  68. {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/information/conditional_mi.py +0 -0
  69. {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/information/entropy.py +0 -0
  70. {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/information/pairs.py +0 -0
  71. {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/information/profile.py +0 -0
  72. {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/information/quadrants.py +0 -0
  73. {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/information/utils.py +0 -0
  74. {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/quantiles/__init__.py +0 -0
  75. {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/report/__init__.py +0 -0
  76. {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/report/dispersion.py +0 -0
  77. {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/segmentation/__init__.py +0 -0
  78. {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/segmentation/apply.py +0 -0
  79. {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/segmentation/hierarchy.py +0 -0
  80. {robustkit-0.5.0 → robustkit-0.7.0}/robustkit.egg-info/dependency_links.txt +0 -0
  81. {robustkit-0.5.0 → robustkit-0.7.0}/robustkit.egg-info/top_level.txt +0 -0
  82. {robustkit-0.5.0 → robustkit-0.7.0}/setup.cfg +0 -0
  83. {robustkit-0.5.0 → robustkit-0.7.0}/tests/test_benchmark.py +0 -0
  84. {robustkit-0.5.0 → robustkit-0.7.0}/tests/test_common_quadrants.py +0 -0
  85. {robustkit-0.5.0 → robustkit-0.7.0}/tests/test_core.py +0 -0
  86. {robustkit-0.5.0 → robustkit-0.7.0}/tests/test_information.py +0 -0
  87. {robustkit-0.5.0 → robustkit-0.7.0}/tests/test_information_pairs.py +0 -0
  88. {robustkit-0.5.0 → robustkit-0.7.0}/tests/test_quantiles_reconstruct.py +0 -0
  89. {robustkit-0.5.0 → robustkit-0.7.0}/tests/test_quantiles_reconstruct_mean_only.py +0 -0
  90. {robustkit-0.5.0 → robustkit-0.7.0}/tests/test_segmentation.py +0 -0
  91. {robustkit-0.5.0 → robustkit-0.7.0}/tests/test_trend_extras.py +0 -0
@@ -0,0 +1,1306 @@
1
+ Metadata-Version: 2.4
2
+ Name: robustkit
3
+ Version: 0.7.0
4
+ Summary: Practical tools for robust analysis of a single continuous relationship: trend fitting, stability checks, influence diagnostics, and bootstrap uncertainty.
5
+ Author: Mikael Lundqvist
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Mikael Lundqvist
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
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
36
+ Requires-Python: >=3.10
37
+ Description-Content-Type: text/markdown
38
+ License-File: LICENSE
39
+ Requires-Dist: numpy>=1.24
40
+ Requires-Dist: pandas>=2.0
41
+ Requires-Dist: scikit-learn>=1.3
42
+ Requires-Dist: statsmodels>=0.14
43
+ Requires-Dist: scipy>=1.10
44
+ Requires-Dist: matplotlib>=3.7
45
+ Provides-Extra: dev
46
+ Requires-Dist: pytest>=7.0; extra == "dev"
47
+ Provides-Extra: excel
48
+ Requires-Dist: openpyxl>=3.1; extra == "excel"
49
+ Dynamic: license-file
50
+
51
+ # robustkit
52
+
53
+ Practical tools for robust analysis of a single continuous relationship:
54
+ y as a function of one continuous x.
55
+
56
+ The guiding idea: **a conclusion that survives multiple fitting methods
57
+ is more trustworthy than one that only holds under a single model.**
58
+ `robustkit` makes it easy to compare Huber, Tukey biweight, and OLS
59
+ fits side by side, identify and quantify the influence of individual
60
+ observations, and get honest, bias-corrected uncertainty estimates.
61
+
62
+ ## Status
63
+
64
+ `robustkit.core` (trend fitting, stability, diagnostics, uncertainty,
65
+ consistency checks), `robustkit.segmentation` (hierarchical grouping,
66
+ per-segment analysis), `robustkit.information` (mutual-information
67
+ feature ranking, quadrant classification, pairwise redundancy/synergy
68
+ scoring), `robustkit.benchmark` (global-trend segment comparison,
69
+ model-agnostic custom benchmarks, residual/deviation reporting, Excel
70
+ export, Robustness Map), `robustkit.report` (analyst vs. publisher
71
+ views, dispersion measures, combined Huber+IQR view),
72
+ `robustkit.quantiles` (generic JSON-stat loading, published-quantile-
73
+ trend visualization, and lognormal-calibrated reconstruction of
74
+ individual-level data from aggregated summaries), and
75
+ `robustkit.segment_awareness` (automatic hierarchical segmentation +
76
+ analysis, no manual hierarchy construction required) are stable and
77
+ tested.
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
+
87
+ **Recent fixes from real-dataset validation:**
88
+ - `rank_features`/`quadrant_report`/`rank_communicative_pairs` no
89
+ longer crash on pandas `Categorical` columns containing missing
90
+ values (found via OpenML's Boston Housing dataset).
91
+ - `bootstrap_band` (and `plot_analyst_view`, which uses it) now
92
+ defaults to `n_boot="auto"`, scaling iterations down for large
93
+ datasets since each iteration refits a full Huber model -- found to
94
+ become impractically slow at n_boot=200 on a ~54,000-row dataset.
95
+ Pass an explicit integer to opt out and always use exactly that many
96
+ iterations.
97
+
98
+ **Note on `information_efficiency`:** values can exceed 1.0 for
99
+ continuous features. `mutual_information` is estimated on the
100
+ full-resolution continuous values, while `entropy_bits` is computed on
101
+ a binned version of the same feature (since `entropy()` expects
102
+ categorical input). Binning discards information, so `entropy_bits` is
103
+ a lower bound on the feature's true entropy -- an efficiency above 1.0
104
+ signals that the feature carries more usable information than a coarse
105
+ categorical summary of it would capture. This is expected behavior,
106
+ not a bug.
107
+
108
+ ## Installation
109
+
110
+ ```bash
111
+ git clone https://github.com/<your-username>/robustkit.git
112
+ cd robustkit
113
+ pip install -e ".[dev]"
114
+ ```
115
+
116
+ ## Quickstart
117
+
118
+ ```python
119
+ import numpy as np
120
+ from robustkit import (
121
+ fit_huber_trend, fit_tukey_trend, predict_trend,
122
+ model_stability_pct, cooks_diagnostic, cook_impact,
123
+ bootstrap_band, bca_bootstrap_ci,
124
+ )
125
+
126
+ # x: a single continuous predictor, y: a single continuous outcome
127
+ x = np.random.default_rng(0).uniform(20, 60, 200)
128
+ y = 1000 + 50 * x - 0.4 * x**2 + np.random.default_rng(1).normal(0, 500, 200)
129
+
130
+ fit = fit_huber_trend(x, y, degree=2)
131
+ y_pred = predict_trend(fit, x_new=[30, 40, 50])
132
+
133
+ stability = model_stability_pct(x, y)
134
+ print("Median % spread between Huber/Tukey/OLS:", stability["median_pct_diff"])
135
+
136
+ diag = cooks_diagnostic(x, y)
137
+ impact = cook_impact(x, y, diag["flagged_indices"])
138
+ print("Median % change in curve if flagged points removed:", impact["median_pct_change"])
139
+
140
+ band = bootstrap_band(x, y)
141
+ ci = bca_bootstrap_ci(x, y, statistic_fn=lambda x_, y_: np.median(y_))
142
+ ```
143
+
144
+ See `examples/quickstart_tutorial.py` for a complete, runnable walkthrough.
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
+
171
+ ## Trend growth rate and goodness of fit
172
+
173
+ ```python
174
+ from robustkit import trend_derivative, goodness_of_fit, compare_polynomial_degrees
175
+
176
+ fit = fit_huber_trend(df["age"], df["salary"])
177
+
178
+ # Rate of change of the trend itself (e.g. "salary growth per year of
179
+ # age"), not just its level
180
+ rates = trend_derivative(fit, x=[30, 40, 50])
181
+
182
+ # How well does this fit actually explain the variation in y?
183
+ goodness_of_fit(df["age"], df["salary"], degree=2)
184
+
185
+ # Don't assume a quadratic trend is always the right choice -- check
186
+ # empirically whether a higher degree captures meaningfully more
187
+ compare_polynomial_degrees(df["age"], df["salary"], degrees=(1, 2, 3, 4))
188
+ ```
189
+
190
+ **Note:** x is standardized internally before building polynomial
191
+ features (both here and throughout `robustkit.core`), since raw
192
+ polynomial features become numerically unstable at higher degrees for
193
+ realistic x scales (e.g. age^5 vastly outscales age^1). This is
194
+ transparent to callers -- `predict_trend` and `trend_derivative` still
195
+ take and return values in the original x scale.
196
+
197
+ ## Segmentation
198
+
199
+ Run any `robustkit.core` analysis independently across subgroups of a
200
+ larger dataset, with automatic fallback to coarser groupings when a
201
+ finer one is too small to analyze reliably:
202
+
203
+ ```python
204
+ from robustkit import hierarchical_segment, apply_by_segment, model_stability_pct
205
+
206
+ hierarchy = [["department", "level", "status"], ["level", "status"], ["status"]]
207
+ segmented = hierarchical_segment(df, hierarchy, min_size=20)
208
+
209
+ report = apply_by_segment(
210
+ segmented, segment_col="segment_id", x_col="age", y_col="value",
211
+ analysis_fn=model_stability_pct,
212
+ )
213
+ ```
214
+
215
+ `apply_by_segment` works with any function shaped like
216
+ `analysis_fn(x, y, **kwargs) -> dict` -- built-in ones
217
+ (`model_stability_pct`, `cook_impact`, `bca_bootstrap_ci`, ...) or your
218
+ own. Only scalar values in the returned dict end up in the report
219
+ table; segments below `min_points` are skipped rather than causing an
220
+ error.
221
+
222
+ **Sanity-checking a segmentation before trusting it:**
223
+ `segment_consistency_report` runs a small battery of checks per
224
+ segment -- does it meet the recommended minimum size, and does fitting
225
+ a Huber trend on it use every row (robust methods don't need outliers
226
+ pre-removed, so a silently dropped row usually means a missing x/y
227
+ value slipped through, not intentional filtering):
228
+
229
+ ```python
230
+ from robustkit import segment_consistency_report
231
+
232
+ segment_consistency_report(df, segment_col="department", x_col="age", y_col="salary", min_size=20)
233
+ # segment n_total n_valid_xy n_dropped_missing_xy size_ok fit_ok fit_error
234
+ ```
235
+
236
+ ## Feature ranking (information)
237
+
238
+ Rank features by mutual information with a target, normalized by each
239
+ feature's own entropy, and classify them into four quadrants:
240
+
241
+ ```python
242
+ from robustkit import rank_features, quadrant_report, plot_feature_space
243
+
244
+ ranking = rank_features(df, target="value")
245
+ report = quadrant_report(df, target="value") # adds a `quadrant` column
246
+ plot_feature_space(df, target="value") # same quadrants, visualized
247
+ ```
248
+
249
+ `quadrant_report` and `plot_feature_space` always agree on quadrant
250
+ assignment -- both route through the same thresholding logic.
251
+
252
+ **Caveat:** default thresholds are the *median* mutual information /
253
+ efficiency across the ranked features. With only a handful of
254
+ features, this can put a genuinely weak feature in the same "high"
255
+ half as a strong one, since roughly half of any list sits above its
256
+ own median regardless of how large the actual gap is. Median
257
+ thresholding becomes meaningful with a reasonably large feature set;
258
+ for a handful of candidates, read the raw `mutual_information` /
259
+ `information_efficiency` values directly rather than relying on the
260
+ quadrant label alone.
261
+
262
+ See `examples/information_tutorial.py` for a complete walkthrough.
263
+
264
+ ## Benchmarking against a global trend
265
+
266
+ Compare each segment's observed outcome against what a benchmark model
267
+ predicts, with bootstrap uncertainty on the difference -- answers
268
+ "which groups deviate from the overall trend, and by how much?" rather
269
+ than "how does the trend look overall?":
270
+
271
+ ```python
272
+ from robustkit import segment_position_report
273
+
274
+ # Default: a single global Huber trend on one continuous x
275
+ report = segment_position_report(
276
+ df, segment_col="department", x_col="age", y_col="salary",
277
+ )
278
+ # segment n observed_median expected_median difference ci_lower ci_upper ci_available
279
+ # Finance 176 48339.70 47799.82 539.88 202.15 1031.01 True
280
+ # HR 174 45718.84 46647.39 -928.55 -1293.26 -580.36 True
281
+ # IT 250 47226.50 47126.91 99.59 -117.56 510.81 True
282
+ ```
283
+
284
+ A segment's confidence interval crossing zero means no clear deviation
285
+ from the benchmark; HR and Finance above don't cross zero, IT does.
286
+
287
+ **Custom, model-agnostic benchmarks:** the default single-column Huber
288
+ trend can be replaced with any richer model -- e.g. one using age,
289
+ age-squared, job level, overtime status, and a reference cluster
290
+ together, rather than a single x. Provide any object exposing
291
+ `predict(dataframe) -> array`:
292
+
293
+ ```python
294
+ report = segment_position_report(
295
+ df, segment_col="department", y_col="salary", benchmark_fit=my_richer_model,
296
+ )
297
+ ```
298
+
299
+ `segment_position_report` never inspects what the model uses
300
+ internally -- it only calls `predict()`.
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
+
324
+ **Small segments:** groups with fewer than `MIN_POINTS_FOR_CI` (default
325
+ 20) observations still get `observed_median` / `expected_median` /
326
+ `difference`, but `ci_lower` / `ci_upper` are `NaN` and
327
+ `ci_available` is `False` -- a BCa bootstrap confidence interval (which
328
+ relies on a jackknife step) is not attempted for populations that
329
+ small, since it can fail outright or become statistically meaningless.
330
+ For segments at or above the threshold, the interval is a full BCa
331
+ (bias-corrected and accelerated) bootstrap interval, via the same
332
+ `bca_bootstrap_ci_by_index` primitive used elsewhere in the package --
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.
456
+
457
+ ## Reporting: residuals, individual deviations, batch runs, and Excel export
458
+
459
+ Four functions built on the same benchmark contract as
460
+ `segment_position_report`, for turning a benchmark into something a
461
+ non-technical audience (or a spreadsheet) can use directly:
462
+
463
+ ```python
464
+ from robustkit import (
465
+ residual_summary, deviation_report,
466
+ benchmark_report_suite, export_benchmark_excel,
467
+ )
468
+
469
+ # Per-segment residual SHAPE (not just the median difference):
470
+ residual_summary(df, segment_col="job_family", y_col="salary", x_col="age")
471
+ # segment n median_residual mad_residual p10_residual p90_residual
472
+
473
+ # Individuals furthest from the benchmark, sorted by residual --
474
+ # material for a conversation, not an automatic flag:
475
+ deviation_report(
476
+ df, y_col="salary", x_col="age", top_n=50, id_cols=["employee_id"],
477
+ )
478
+ # employee_id actual expected residual
479
+
480
+ # direction="negative" (default, furthest below), "positive" (furthest
481
+ # above), or "two_sided" (largest |residual| either direction).
482
+
483
+ # Run the same benchmark across several grouping columns at once,
484
+ # reusing ONE fitted benchmark so results are directly comparable:
485
+ reports = benchmark_report_suite(
486
+ df, group_columns=["gender", "job_family", "location"], y_col="salary", x_col="age",
487
+ )
488
+ # -> {"gender": DataFrame, "job_family": DataFrame, "location": DataFrame}
489
+
490
+ # Every report as its own sheet in one workbook:
491
+ export_benchmark_excel(reports, "salary_report.xlsx")
492
+ ```
493
+
494
+ All four accept the same `benchmark_fit` / `x_col` contract as
495
+ `segment_position_report` (default single-column Huber trend, or any
496
+ custom model exposing `predict(dataframe)`).
497
+
498
+ **Dependency-free Excel export:** `export_benchmark_excel` requires
499
+ `openpyxl` (an optional dependency). In an offline/air-gapped
500
+ environment where installing it isn't possible, use
501
+ `export_benchmark_excel_no_deps` instead -- identical interface,
502
+ implemented with only the Python standard library (writes valid
503
+ `.xlsx` files via `zipfile` and OOXML templating directly, no
504
+ third-party package required):
505
+
506
+ ```python
507
+ from robustkit import export_benchmark_excel_no_deps
508
+
509
+ export_benchmark_excel_no_deps(reports, "salary_report.xlsx")
510
+ ```
511
+
512
+ Prefer `export_benchmark_excel` when `openpyxl` is available -- it's a
513
+ more complete, better-tested implementation of the Excel format.
514
+
515
+ ## Robustness Map
516
+
517
+ Classify features by how much a conclusion about their relationship
518
+ with the target depends on (a) fitting method choice and (b) specific
519
+ influential observations -- two genuinely different failure modes that
520
+ a single diagnostic can miss:
521
+
522
+ ```python
523
+ from robustkit import feature_robustness_report, plot_feature_robustness
524
+
525
+ report = feature_robustness_report(df, target="value")
526
+ # feature stability_pct cook_impact_pct quadrant
527
+ # CRIM 8.9 17.1 fragile
528
+ # AGE 16.8 15.5 fragile
529
+ # RM 4.9 0.1 robust
530
+ # TAX 22.2 1.4 structural_sensitivity
531
+
532
+ plot_feature_robustness(report=report)
533
+ ```
534
+
535
+ Four quadrants: **robust** (low spread, low impact), **structural
536
+ sensitivity** (sensitive to fitting method, not to specific points),
537
+ **data sensitive** (a few points drive the conclusion, method choice
538
+ barely matters), **fragile** (both -- least trustworthy).
539
+
540
+ `quadrant_report`/`plot_feature_space` (information) and
541
+ `feature_robustness_report`/`plot_feature_robustness` (benchmark) both
542
+ route through the same shared classifier, `robustkit.classify_quadrants`
543
+ -- any future quadrant-based analysis in this package will too.
544
+
545
+ ## Analyst view vs. publisher view
546
+
547
+ Two visualizations that look superficially similar but answer
548
+ genuinely different questions:
549
+
550
+ ```python
551
+ from robustkit import plot_analyst_view, plot_publisher_view, dispersion_ratio, iqr
552
+
553
+ # "How confident are we in the trend estimate?" -- a bootstrap
554
+ # confidence band that SHRINKS as sample size grows.
555
+ plot_analyst_view(df["age"], df["salary"])
556
+
557
+ # "How spread out are actual values in the population?" -- a median +
558
+ # IQR band that does NOT shrink with more data, since it reflects
559
+ # real dispersion, not estimation uncertainty. show_points defaults to
560
+ # False, since this view is meant for publishing potentially sensitive
561
+ # data (e.g. individual salaries) without exposing raw points.
562
+ plot_publisher_view(df["age"], df["salary"])
563
+ ```
564
+
565
+ This distinction matters in practice: with 20x more data (same
566
+ underlying distribution), the analyst view's confidence band roughly
567
+ halves in width, while the publisher view's IQR band stays essentially
568
+ unchanged -- confirmed by the package's own test suite.
569
+
570
+ Both accept an optional `title=None` to override the default title
571
+ (e.g. `plot_analyst_view(x, y, title="Q3 salary review")`), as does
572
+ `plot_quantile_trend`.
573
+
574
+ `dispersion_ratio(y)` -- (Q3-Q1)/median -- and `iqr(y)` are available
575
+ standalone for tabular reporting; `dispersion_by_bin(x, y, n_bins=10)`
576
+ computes both across bins of a continuous x, e.g. to check whether
577
+ dispersion (inequality) grows with age.
578
+
579
+ ## Segment awareness: automatic hierarchical grouping + analysis
580
+
581
+ `segment_stability_report` and `segment_benchmark_report` build the
582
+ hierarchical segmentation automatically from a flat, most-specific-
583
+ first list of columns, then run an existing analysis within the
584
+ result -- no separate `hierarchical_segment(...)` + `apply_by_segment(...)`
585
+ preparation step required:
586
+
587
+ ```python
588
+ from robustkit import segment_stability_report, segment_benchmark_report
589
+
590
+ # hierarchy built automatically: [JobFamily, Level, OT] -> [Level, OT] -> [OT] -> ALL
591
+ report = segment_stability_report(
592
+ df, x_col="age", y_col="salary",
593
+ segment_cols=["JobFamily", "Level", "OT"], min_size=20,
594
+ )
595
+
596
+ report = segment_benchmark_report(
597
+ df, y_col="salary", segment_cols=["JobFamily", "Level", "OT"],
598
+ x_col="age", min_size=20, # or benchmark_fit=my_custom_model
599
+ )
600
+ ```
601
+
602
+ Both add a `segment_level` column showing which tier of the hierarchy
603
+ each reported segment actually landed on (0 = finest), so a fallback
604
+ to a coarser grouping is visible rather than silent. These are pure
605
+ convenience wrappers -- identical results to building the hierarchy
606
+ by hand with `hierarchical_segment` and calling `apply_by_segment` /
607
+ `segment_position_report` directly.
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
+
629
+ `mad_outlier_report` flags individuals whose residual is an outlier
630
+ relative to their OWN segment's typical spread (MAD), not the whole
631
+ population -- built on the same automatic hierarchical segmentation as
632
+ above, so even someone in a small segment is compared against a
633
+ sensibly-sized reference group rather than an irrelevant one:
634
+
635
+ ```python
636
+ from robustkit import mad_outlier_report
637
+
638
+ report = mad_outlier_report(
639
+ df, y_col="salary", segment_cols=["JobFamily", "Level", "OT"], x_col="age",
640
+ min_size=20, k=3.0, direction="negative", id_cols=["employee_id"],
641
+ )
642
+ # employee_id actual expected residual residual_pct segment segment_level segment_mad threshold flagged
643
+ ```
644
+
645
+ `direction`: `"negative"` (default -- flag underperformance relative
646
+ to the benchmark), `"positive"`, or `"two_sided"`. Flagging compares
647
+ each residual to `k` MADs from its *own segment's* median residual
648
+ (not literally zero), so a segment the benchmark is systematically
649
+ biased for doesn't get every member flagged just for that bias.
650
+ Segments with zero MAD (a degenerate case, usually a tiny segment
651
+ where every residual happens to match) are treated as having an
652
+ infinite threshold rather than flagging everyone in them.
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
+
674
+ `export_outlier_pdf` renders one chart per segment -- built from the
675
+ same segmentation and flagging as `mad_outlier_report` -- as a
676
+ one-page-per-segment PDF, for visual verification alongside the
677
+ numeric report:
678
+
679
+ ```python
680
+ from robustkit import export_outlier_pdf
681
+
682
+ export_outlier_pdf(
683
+ df, y_col="salary", segment_cols=["JobFamily", "Level", "OT"], x_col="age",
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
686
+ )
687
+ ```
688
+
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.
732
+ Segments with fewer than `min_points_to_plot` (default 5) observations
733
+ are skipped in the PDF -- a chart with a handful of points isn't
734
+ meaningfully verifiable -- but still appear in `mad_outlier_report`'s
735
+ numeric output. The idea: a numeric flag and a visual confirmation are
736
+ two independent checks, and agreement between them is stronger
737
+ evidence than either alone.
738
+
739
+ ### Drilldown reports: every hierarchy level at once, without exclusive assignment
740
+
741
+ `segment_stability_report`, `segment_benchmark_report`, and
742
+ `mad_outlier_report` each assign every individual to exactly ONE
743
+ segment (their most specific grouping meeting `min_size`). That
744
+ answers "what is the single most relevant reference population for
745
+ THIS individual?"
746
+
747
+ `segment_benchmark_drilldown_report` and `mad_outlier_drilldown_report`
748
+ answer a different question -- "what does every granularity level look
749
+ like on its own?" -- by reporting EVERY level of the hierarchy
750
+ independently, without exclusive assignment. The same individual can
751
+ appear in multiple rows (e.g. once in a `JobFamily x Level x OT` row,
752
+ and again in the broader `Level x OT` row), whenever both groupings
753
+ independently meet `min_size`:
754
+
755
+ ```python
756
+ from robustkit import (
757
+ segment_benchmark_drilldown_report, mad_outlier_drilldown_report,
758
+ segment_stability_drilldown_report, outlier_drilldown_summary,
759
+ )
760
+
761
+ segment_benchmark_drilldown_report(
762
+ df, y_col="salary", segment_cols=["JobFamily", "Level", "OT"], x_col="age", min_size=20,
763
+ )
764
+ mad_outlier_drilldown_report(
765
+ df, y_col="salary", segment_cols=["JobFamily", "Level", "OT"], x_col="age", k=3.0,
766
+ )
767
+ segment_stability_drilldown_report(
768
+ df, x_col="age", y_col="salary", segment_cols=["JobFamily", "Level", "OT"], min_size=20,
769
+ )
770
+ ```
771
+
772
+ Both add a `segment_level` column, and the sum of `n` across rows will
773
+ exceed the population size -- that's the expected signature of
774
+ overlap, not a bug. Use the exclusive functions when you need to route
775
+ each individual to one home; use the drilldown functions when you want
776
+ to see every level side by side.
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
+
899
+ ## Combined model + spread view
900
+
901
+ `plot_analyst_view` and `plot_publisher_view` each show one thing --
902
+ estimation uncertainty, or population spread -- deliberately kept
903
+ separate. `plot_huber_iqr` shows both together: one or more trend
904
+ curves overlaid with median + IQR error bars, plus an optional
905
+ residual-quality box, matching the combined model-and-spread diagram
906
+ style common in salary/wage analysis reporting:
907
+
908
+ ```python
909
+ from robustkit import plot_huber_iqr
910
+
911
+ result = plot_huber_iqr(df["age"], df["salary"], degree=2, bins=15)
912
+ # result["grid"], result["huber"], result["binned"]
913
+ ```
914
+
915
+ `show_points` defaults to `False`, consistent with `plot_publisher_view`.
916
+
917
+ **Multiple curves, bootstrap bands, and full style control:**
918
+
919
+ ```python
920
+ plot_huber_iqr(
921
+ df["age"], df["salary"],
922
+ methods=("huber", "tukey", "ols", "median_ensemble"), # overlay all four
923
+ show_bootstrap_band=True, bootstrap_levels=(95, 50), # nested confidence bands
924
+ cap_style="manual", # hand-drawn boxplot-style Q1/Q3 "hats" instead of matplotlib's default caps
925
+ residual_box_metric="mdape", # MdAPE + IQR(resid) instead of R^2/MAE/RMSE
926
+ ylim="dynamic", # y-limits set from the data (min*0.95, max*1.05)
927
+ style={
928
+ "huber_line": {"color": "red", "linewidth": 2, "linestyle": "-", "label": "Huber poly(2)"},
929
+ "iqr_color": "black", "cap_width": 0.15,
930
+ },
931
+ )
932
+ ```
933
+
934
+ `methods` selects which trend curve(s) to draw (`"tukey"` and
935
+ `"median_ensemble"` -- the pointwise median of Huber/Tukey/OLS --
936
+ require statsmodels). `style` overrides individual colors, line
937
+ widths, and other visual details without needing to touch anything
938
+ else; every new parameter here defaults to the original, simpler
939
+ single-Huber-curve appearance, so existing calls are unaffected.
940
+
941
+ **Two grouping strategies:** `grouping="bin"` (default) uses quantile-
942
+ based binning for stable estimates even in small populations.
943
+ `grouping="unique"` instead groups by each EXACT x value (e.g. every
944
+ individual age in years) -- matching a workbook-style `groupby(x)`
945
+ aggregation -- and, when a given x value has fewer than
946
+ `min_n_for_iqr` (default 5) observations, omits its IQR error bar
947
+ entirely rather than showing an unreliable one:
948
+
949
+ **Caveat, found via validation against a real dataset:** `grouping="unique"`
950
+ only makes sense for x values with natural repetition (e.g. integer
951
+ ages) -- for a genuinely continuous, high-precision measurement (e.g.
952
+ carat weight to several decimal places), nearly every x value is
953
+ unique, so almost nothing meets `min_n_for_iqr` and the result shows
954
+ no IQR bars at all. Use `grouping="bin"` (the default) for
955
+ high-precision continuous x; reserve `grouping="unique"` for x values
956
+ that naturally repeat.
957
+
958
+ ```python
959
+ plot_huber_iqr(
960
+ df["age"], df["salary"], grouping="unique", min_n_for_iqr=5,
961
+ )
962
+ ```
963
+
964
+ The absence of an error bar at a given age is itself information --
965
+ it signals the sample at that exact value is too small to say
966
+ anything about spread, not just a plotting simplification.
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
+
1051
+ ## Loading published quantile tables (SCB / JSON-stat)
1052
+
1053
+ Some statistics agencies (e.g. Statistics Sweden, SCB) publish
1054
+ quantiles (Q1/median/Q3) directly, with no individual-level data
1055
+ available at all. `robustkit.quantiles` loads these tables generically
1056
+ via JSON-stat, a standardized dimensional-data format used by SCB and
1057
+ other national statistics agencies -- avoiding the fragility of
1058
+ parsing metadata out of column-name strings in a wide CSV export.
1059
+
1060
+ ```python
1061
+ from robustkit import load_json_stat, plot_quantile_trend, quantile_trend_dispersion
1062
+
1063
+ df = load_json_stat("some_scb_table.json")
1064
+
1065
+ # A real SCB quirk this loader does NOT try to guess automatically:
1066
+ # category labels can change meaning over time (e.g. Sweden's oldest
1067
+ # working-age bracket was labeled "65-66 år" through 2022 and
1068
+ # "65-68 år" from 2023, following a pension-age reform). Merge such
1069
+ # cases explicitly:
1070
+ df = load_json_stat(
1071
+ "some_scb_table.json",
1072
+ rename_categories={"ålder": {"65–68 år": "65–66 år"}},
1073
+ )
1074
+
1075
+ # Once reshaped to a wide table with q1/median/q3 columns:
1076
+ plot_quantile_trend(wide_df, x_col="år", q1_col="q1", median_col="median", q3_col="q3")
1077
+ quantile_trend_dispersion(wide_df, x_col="år", q1_col="q1", median_col="median", q3_col="q3")
1078
+ ```
1079
+
1080
+ This is the "quantiles are already given" case. A complementary case
1081
+ -- reconstructing approximate individual-level data from aggregated
1082
+ group means, for when only summary statistics (not quantiles) are
1083
+ available -- is planned as a follow-up (`robustkit.quantiles.reconstruct`).
1084
+
1085
+ See `examples/quantiles_tutorial.py` for a complete walkthrough.
1086
+
1087
+ ## Reconstructing individual-level data from aggregated summaries
1088
+
1089
+ For the complementary case -- only aggregated group summaries (n,
1090
+ Q1, median, Q3) are available, not the quantile trend itself as the
1091
+ final answer, and you want to run `robustkit.core` analyses as if
1092
+ individual data existed:
1093
+
1094
+ ```python
1095
+ from robustkit import expand_aggregated_table, check_reconstruction_quality, fit_huber_trend
1096
+
1097
+ # One row per group (e.g. year), with n/q1/median/q3 columns
1098
+ synthetic = expand_aggregated_table(
1099
+ summary_df, n_col="n", q1_col="q1", median_col="median", q3_col="q3",
1100
+ group_cols=["year"], value_name="salary",
1101
+ )
1102
+
1103
+ # Now usable exactly like real individual-level data:
1104
+ fit = fit_huber_trend(synthetic["year"], synthetic["salary"])
1105
+ ```
1106
+
1107
+ Method: a lognormal distribution is calibrated (via the IQR) to match
1108
+ each group's reported Q1/median/Q3, then `n` synthetic values are
1109
+ drawn from it. Validated end-to-end against real published SCB salary
1110
+ data: a Huber trend fitted on reconstructed pseudo-individual data
1111
+ tracked the true published median trend within 2% across 12 years.
1112
+
1113
+ **Note on what this recovers:** because a Huber (or Tukey) fit on
1114
+ right-skewed reconstructed data tracks something close to the
1115
+ *median* trend it was calibrated against -- not the arithmetic mean --
1116
+ this is consistent with, not a limitation of, the reconstruction
1117
+ method. To target the mean instead, fit on `log(value)` and
1118
+ exponentiate predictions back, which approximates the geometric mean.
1119
+
1120
+ Always check `check_reconstruction_quality()` before trusting a
1121
+ reconstruction: real Q1/median/Q3 triples aren't always perfectly
1122
+ consistent with a pure lognormal shape.
1123
+
1124
+ **Warning -- unbounded tail at large n:** a lognormal has no natural
1125
+ upper limit, and its expected maximum grows with n. Reconstructing at
1126
+ the TRUE group size from a national table (SCB salary tables can
1127
+ report n in the hundreds of thousands to millions) can produce
1128
+ implausibly extreme tail values -- real salaries have practical
1129
+ ceilings a pure lognormal doesn't know about. This package's own
1130
+ examples and tests deliberately scale n down to a few thousand for
1131
+ demonstration; calibration quality (matching Q1/median/Q3) doesn't
1132
+ depend on reproducing the true population size, but tail plausibility
1133
+ does. No clipping is applied automatically.
1134
+
1135
+ ### When only a mean is available (no quantiles at all)
1136
+
1137
+ Some tables (e.g. SCB's age-breakdown salary tables) report only a
1138
+ mean per group, with no spread information. Two deliberately separate
1139
+ methods are provided, each making a different explicit assumption --
1140
+ compare them rather than silently picking one:
1141
+
1142
+ ```python
1143
+ from robustkit import (
1144
+ expand_aggregated_table_flat, expand_aggregated_table_borrowed_dispersion,
1145
+ compare_reconstruction_methods,
1146
+ )
1147
+
1148
+ # Method 1: repeat the mean n times -- zero within-group spread.
1149
+ # Equivalent to weighting each group by n (see "What the flat method
1150
+ # actually is", below); recovers between-group regression coefficients
1151
+ # (validated in the original technique this is based on) but
1152
+ # understates individual-level variation.
1153
+ flat = expand_aggregated_table_flat(df, n_col="n", mean_col="mean_salary", group_cols=["age"])
1154
+
1155
+ # Method 2: borrow a dispersion_ratio from a DIFFERENT table that does
1156
+ # report quantiles, and use it to imply an approximate spread around
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.
1162
+ borrowed = expand_aggregated_table_borrowed_dispersion(
1163
+ df, n_col="n", mean_col="mean_salary", dispersion_ratio=0.45, group_cols=["age"],
1164
+ )
1165
+
1166
+ # Compare both for a single group directly:
1167
+ compare_reconstruction_methods(n=2000, mean=52200, dispersion_ratio=0.45)
1168
+ ```
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
+
1217
+ Both methods are documented with their specific assumptions rather
1218
+ than presented as equally valid defaults -- being explicit about which
1219
+ assumption was made lets the analyst judge how much a conclusion
1220
+ depends on it, rather than presenting an assumption as a measurement.
1221
+
1222
+ ## Feature pairing (information)
1223
+
1224
+ Beyond ranking single features, evaluate *pairs* of features together:
1225
+ how redundant are they with each other, and does knowing one reveal
1226
+ additional predictive value in the other (synergy, e.g. an interaction
1227
+ effect)?
1228
+
1229
+ ```python
1230
+ from robustkit import (
1231
+ conditional_mutual_information, communication_score,
1232
+ rank_by_communication, pair_redundancy, pair_synergy,
1233
+ rank_communicative_pairs,
1234
+ )
1235
+
1236
+ # How communicable is a single feature -- not just predictive, but
1237
+ # suitable for a clear chart/table (adequate group sizes, homogeneous
1238
+ # groups, few enough categories to show at once)?
1239
+ comm_ranking = rank_by_communication(df, target="value")
1240
+
1241
+ # How much does region's relevance to the target change once
1242
+ # department is already known?
1243
+ synergy = pair_synergy(df, feature_1="department", feature_2="region", target="value")
1244
+
1245
+ # Rank every candidate pair by combined relevance, penalizing
1246
+ # redundant pairs and rewarding genuine synergy
1247
+ pairs = rank_communicative_pairs(df, target="value")
1248
+ ```
1249
+
1250
+ All mutual-information-based quantities in this module (`rank_features`,
1251
+ `conditional_mutual_information`, `pair_redundancy`, `pair_synergy`,
1252
+ `communication_score`) are expressed in **bits**, consistent with
1253
+ `entropy()` -- internally, scikit-learn's MI estimators return nats
1254
+ and are converted before being used anywhere in this package.
1255
+
1256
+ ## Design principles
1257
+
1258
+ - **One continuous x, one continuous y** at the core. This keeps every
1259
+ function's output visually and numerically interpretable (a curve
1260
+ you can plot, a band you can read).
1261
+ - **Diagnosis and action are separate steps.** `cooks_diagnostic`
1262
+ flags candidates; `cook_impact` tells you whether removing them
1263
+ actually changes anything.
1264
+ - **OLS is a reference point, not the enemy.** Comparing robust fits
1265
+ against OLS is how you know whether robustness mattered at all.
1266
+
1267
+ ## Naming conventions
1268
+
1269
+ A few parameter/column names look similar across the package but mean
1270
+ different things -- documented here explicitly so the difference reads
1271
+ as intentional, not as an inconsistency to "fix":
1272
+
1273
+ - **`residual` vs. `difference`:** `residual` is an INDIVIDUAL-level
1274
+ quantity (`actual - expected` for one row) -- used by
1275
+ `mad_outlier_report`, `mad_outlier_drilldown_report`, and
1276
+ `deviation_report`. `difference` is a SEGMENT/GROUP-level quantity
1277
+ (typically the median residual within a group) -- used by
1278
+ `segment_position_report`, `segment_benchmark_report`,
1279
+ `segment_benchmark_drilldown_report`, and `benchmark_report_suite`.
1280
+ - **`target` vs. `y_col`:** `robustkit.information` uses `target` for
1281
+ the column being explained, since it works with arbitrary features
1282
+ (not necessarily a continuous regression outcome).
1283
+ `robustkit.benchmark`, `robustkit.segment_awareness`, and
1284
+ `robustkit.quantiles` use `y_col`, since they specifically model a
1285
+ continuous `y` as a function of `x`.
1286
+ - **`segment_cols` vs. `group_columns`:** `segment_cols` (throughout
1287
+ `robustkit.segment_awareness`) is an ORDERED, most-specific-first
1288
+ list used to build a fallback HIERARCHY (see `hierarchical_segment`).
1289
+ `group_columns` (`benchmark_report_suite`) is a FLAT list of
1290
+ independent groupings, run separately with no hierarchy or fallback
1291
+ between them. Different structure, different name on purpose.
1292
+ - **`min_size` vs. `min_points` vs. `min_stratum_size` vs.
1293
+ `min_group_size`:** all mean "minimum group size," but at different
1294
+ stages: `min_size` (`hierarchical_segment` and everything built on
1295
+ it) gates whether a hierarchy LEVEL gets created at all;
1296
+ `min_points` (`apply_by_segment`) gates whether an already-built
1297
+ segment gets ANALYZED; `min_stratum_size`
1298
+ (`conditional_mutual_information`) and `min_group_size`
1299
+ (`communication_score`, `rank_by_communication`) are specific to
1300
+ those `robustkit.information` calculations. Kept separate rather
1301
+ than unified to one name, since collapsing them would obscure which
1302
+ stage of a pipeline each threshold actually applies to.
1303
+
1304
+ ## License
1305
+
1306
+ MIT -- see [LICENSE](LICENSE).