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