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.
- robustkit-0.7.0/PKG-INFO +1306 -0
- robustkit-0.7.0/README.md +1256 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/pyproject.toml +12 -1
- {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/__init__.py +46 -6
- robustkit-0.7.0/robustkit/benchmark/global_model.py +422 -0
- robustkit-0.7.0/robustkit/benchmark/reporting.py +317 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/benchmark/robustness_map.py +1 -1
- robustkit-0.7.0/robustkit/core/consistency.py +90 -0
- robustkit-0.7.0/robustkit/core/pointwise.py +161 -0
- robustkit-0.7.0/robustkit/core/uncertainty.py +206 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/information/mutual_info.py +13 -1
- {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/information/visualization.py +1 -1
- {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/quantiles/io.py +1 -1
- {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/quantiles/reconstruct.py +47 -1
- {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/quantiles/trend.py +3 -2
- {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/report/visualize_analyst.py +7 -2
- robustkit-0.7.0/robustkit/report/visualize_huber_iqr.py +359 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/report/visualize_publisher.py +3 -2
- robustkit-0.7.0/robustkit/segment_awareness/__init__.py +17 -0
- robustkit-0.7.0/robustkit/segment_awareness/reports.py +1511 -0
- robustkit-0.7.0/robustkit.egg-info/PKG-INFO +1306 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/robustkit.egg-info/SOURCES.txt +30 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/robustkit.egg-info/requires.txt +3 -0
- robustkit-0.7.0/tests/test_benchmark_curve_reference.py +149 -0
- robustkit-0.7.0/tests/test_benchmark_gaps.py +159 -0
- robustkit-0.7.0/tests/test_benchmark_goodness_of_fit.py +114 -0
- robustkit-0.7.0/tests/test_benchmark_reporting.py +195 -0
- robustkit-0.7.0/tests/test_benchmark_screening_report.py +143 -0
- robustkit-0.7.0/tests/test_bugfixes_faseA.py +102 -0
- robustkit-0.7.0/tests/test_dual_reference_outlier.py +120 -0
- robustkit-0.7.0/tests/test_export_no_deps.py +119 -0
- robustkit-0.7.0/tests/test_export_outlier_pdf.py +153 -0
- robustkit-0.7.0/tests/test_huber_iqr.py +314 -0
- robustkit-0.7.0/tests/test_huber_iqr_pdf_and_title_fn.py +196 -0
- robustkit-0.7.0/tests/test_jackknife_cap_performance.py +137 -0
- robustkit-0.7.0/tests/test_mad_outlier_report.py +117 -0
- robustkit-0.7.0/tests/test_n_boot_auto_propagation.py +86 -0
- robustkit-0.7.0/tests/test_plot_segment_vs_benchmark.py +115 -0
- robustkit-0.7.0/tests/test_pointwise_stability.py +116 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/tests/test_quantiles_io_trend.py +17 -9
- {robustkit-0.5.0 → robustkit-0.7.0}/tests/test_report.py +20 -0
- robustkit-0.7.0/tests/test_segment_awareness.py +124 -0
- robustkit-0.7.0/tests/test_segment_consistency.py +72 -0
- robustkit-0.7.0/tests/test_segment_contribution.py +72 -0
- robustkit-0.7.0/tests/test_segment_drilldown.py +84 -0
- robustkit-0.7.0/tests/test_tier1_drilldown_symmetry.py +251 -0
- robustkit-0.7.0/tests/test_tier1_outlier_pdf_fixes.py +197 -0
- robustkit-0.7.0/tests/test_tier1_reference_population.py +78 -0
- robustkit-0.7.0/tests/test_tier2_local_mode_and_quality.py +131 -0
- robustkit-0.7.0/tests/test_tier2_reference_parameter.py +182 -0
- robustkit-0.5.0/PKG-INFO +0 -446
- robustkit-0.5.0/README.md +0 -406
- robustkit-0.5.0/robustkit/benchmark/global_model.py +0 -74
- robustkit-0.5.0/robustkit/core/consistency.py +0 -33
- robustkit-0.5.0/robustkit/core/uncertainty.py +0 -83
- robustkit-0.5.0/robustkit.egg-info/PKG-INFO +0 -446
- {robustkit-0.5.0 → robustkit-0.7.0}/LICENSE +0 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/benchmark/__init__.py +0 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/common/__init__.py +0 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/common/quadrants.py +0 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/core/__init__.py +0 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/core/diagnostics.py +0 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/core/goodness_of_fit.py +0 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/core/stability.py +0 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/core/trend.py +0 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/information/__init__.py +0 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/information/communication.py +0 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/information/conditional_mi.py +0 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/information/entropy.py +0 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/information/pairs.py +0 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/information/profile.py +0 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/information/quadrants.py +0 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/information/utils.py +0 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/quantiles/__init__.py +0 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/report/__init__.py +0 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/report/dispersion.py +0 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/segmentation/__init__.py +0 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/segmentation/apply.py +0 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/robustkit/segmentation/hierarchy.py +0 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/robustkit.egg-info/dependency_links.txt +0 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/robustkit.egg-info/top_level.txt +0 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/setup.cfg +0 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/tests/test_benchmark.py +0 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/tests/test_common_quadrants.py +0 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/tests/test_core.py +0 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/tests/test_information.py +0 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/tests/test_information_pairs.py +0 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/tests/test_quantiles_reconstruct.py +0 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/tests/test_quantiles_reconstruct_mean_only.py +0 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/tests/test_segmentation.py +0 -0
- {robustkit-0.5.0 → robustkit-0.7.0}/tests/test_trend_extras.py +0 -0
robustkit-0.7.0/PKG-INFO
ADDED
|
@@ -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).
|