robustkit 0.0.1__py3-none-any.whl
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/__init__.py +54 -0
- robustkit/core/__init__.py +0 -0
- robustkit/core/consistency.py +33 -0
- robustkit/core/diagnostics.py +94 -0
- robustkit/core/stability.py +48 -0
- robustkit/core/trend.py +86 -0
- robustkit/core/uncertainty.py +83 -0
- robustkit/information/__init__.py +0 -0
- robustkit/information/entropy.py +19 -0
- robustkit/information/mutual_info.py +87 -0
- robustkit/information/profile.py +32 -0
- robustkit/information/quadrants.py +67 -0
- robustkit/information/visualization.py +79 -0
- robustkit/segmentation/__init__.py +0 -0
- robustkit/segmentation/apply.py +49 -0
- robustkit/segmentation/hierarchy.py +49 -0
- robustkit-0.0.1.dist-info/METADATA +169 -0
- robustkit-0.0.1.dist-info/RECORD +21 -0
- robustkit-0.0.1.dist-info/WHEEL +5 -0
- robustkit-0.0.1.dist-info/licenses/LICENSE +21 -0
- robustkit-0.0.1.dist-info/top_level.txt +1 -0
robustkit/__init__.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""
|
|
2
|
+
robustkit
|
|
3
|
+
=========
|
|
4
|
+
|
|
5
|
+
Practical tools for robust analysis of a single continuous relationship
|
|
6
|
+
(y as a function of one continuous x), designed around one core idea:
|
|
7
|
+
a conclusion that survives multiple fitting methods is more trustworthy
|
|
8
|
+
than one that only holds under a single model.
|
|
9
|
+
|
|
10
|
+
Modules:
|
|
11
|
+
robustkit.core -- trend fitting, stability, diagnostics, uncertainty
|
|
12
|
+
robustkit.segmentation -- hierarchical grouping, per-segment analysis
|
|
13
|
+
robustkit.information -- mutual-information-based feature ranking
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from .core.trend import fit_huber_trend, fit_tukey_trend, fit_ols_trend, predict_trend
|
|
17
|
+
from .core.stability import model_stability_pct
|
|
18
|
+
from .core.diagnostics import cooks_diagnostic, cook_impact
|
|
19
|
+
from .core.uncertainty import bootstrap_band, bca_bootstrap_ci
|
|
20
|
+
from .core.consistency import check_row_integrity, compare_row_sets
|
|
21
|
+
from .segmentation.hierarchy import hierarchical_segment, segment_sizes
|
|
22
|
+
from .segmentation.apply import apply_by_segment
|
|
23
|
+
from .information.entropy import entropy
|
|
24
|
+
from .information.mutual_info import rank_features, information_efficiency
|
|
25
|
+
from .information.quadrants import quadrant_report
|
|
26
|
+
from .information.visualization import plot_feature_space, feature_map
|
|
27
|
+
from .information.profile import profile, print_profile
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"fit_huber_trend",
|
|
31
|
+
"fit_tukey_trend",
|
|
32
|
+
"fit_ols_trend",
|
|
33
|
+
"predict_trend",
|
|
34
|
+
"model_stability_pct",
|
|
35
|
+
"cooks_diagnostic",
|
|
36
|
+
"cook_impact",
|
|
37
|
+
"bootstrap_band",
|
|
38
|
+
"bca_bootstrap_ci",
|
|
39
|
+
"check_row_integrity",
|
|
40
|
+
"compare_row_sets",
|
|
41
|
+
"hierarchical_segment",
|
|
42
|
+
"segment_sizes",
|
|
43
|
+
"apply_by_segment",
|
|
44
|
+
"entropy",
|
|
45
|
+
"rank_features",
|
|
46
|
+
"information_efficiency",
|
|
47
|
+
"quadrant_report",
|
|
48
|
+
"plot_feature_space",
|
|
49
|
+
"feature_map",
|
|
50
|
+
"profile",
|
|
51
|
+
"print_profile",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
__version__ = "0.0.1"
|
|
File without changes
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Generic integrity checks, independent of any specific fitting method.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def check_row_integrity(df, group_col):
|
|
7
|
+
total_rows = len(df)
|
|
8
|
+
n_missing_group = int(df[group_col].isna().sum())
|
|
9
|
+
grouped_rows = int(df.groupby(group_col, observed=True).size().sum())
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
"total_rows": total_rows,
|
|
13
|
+
"rows_accounted_for": grouped_rows,
|
|
14
|
+
"rows_missing_group_label": n_missing_group,
|
|
15
|
+
"consistent": total_rows == grouped_rows + 0,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def compare_row_sets(before_df, after_df, key_col):
|
|
20
|
+
before_keys = set(before_df[key_col])
|
|
21
|
+
after_keys = set(after_df[key_col])
|
|
22
|
+
|
|
23
|
+
dropped = before_keys - after_keys
|
|
24
|
+
added = after_keys - before_keys
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
"n_before": len(before_keys),
|
|
28
|
+
"n_after": len(after_keys),
|
|
29
|
+
"n_dropped": len(dropped),
|
|
30
|
+
"n_added": len(added),
|
|
31
|
+
"dropped_keys": dropped,
|
|
32
|
+
"added_keys": added,
|
|
33
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Identify individually influential observations, and quantify how much
|
|
3
|
+
they actually change the fitted trend if removed.
|
|
4
|
+
|
|
5
|
+
cooks_diagnostic() answers "which points stand out?"
|
|
6
|
+
cook_impact() answers "how much does it matter if we remove them?" --
|
|
7
|
+
these are deliberately separate steps, since a flagged point is not
|
|
8
|
+
automatically a point worth acting on.
|
|
9
|
+
|
|
10
|
+
Cook's distance is computed here via plain OLS and the hat matrix
|
|
11
|
+
(pure numpy/scikit-learn), so this module has no statsmodels
|
|
12
|
+
dependency.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from .trend import _design_matrix, fit_huber_trend, predict_trend
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def cooks_diagnostic(x, y, degree=2):
|
|
21
|
+
"""
|
|
22
|
+
Compute Cook's distance for each observation, based on an OLS fit
|
|
23
|
+
of y ~ poly(x, degree). Flags points above the conventional 4/n
|
|
24
|
+
threshold.
|
|
25
|
+
"""
|
|
26
|
+
x = np.asarray(x, dtype=float)
|
|
27
|
+
y = np.asarray(y, dtype=float)
|
|
28
|
+
n = len(y)
|
|
29
|
+
|
|
30
|
+
X, _ = _design_matrix(x, degree)
|
|
31
|
+
X_design = np.column_stack([np.ones(n), X])
|
|
32
|
+
p = X_design.shape[1]
|
|
33
|
+
|
|
34
|
+
beta, *_ = np.linalg.lstsq(X_design, y, rcond=None)
|
|
35
|
+
y_hat = X_design @ beta
|
|
36
|
+
residuals = y - y_hat
|
|
37
|
+
|
|
38
|
+
mse = np.sum(residuals ** 2) / (n - p)
|
|
39
|
+
|
|
40
|
+
Q, R = np.linalg.qr(X_design)
|
|
41
|
+
leverage = np.sum(Q ** 2, axis=1)
|
|
42
|
+
leverage = np.clip(leverage, 1e-12, 1 - 1e-12)
|
|
43
|
+
|
|
44
|
+
cooks_d = (residuals ** 2 / (p * mse)) * (leverage / (1 - leverage) ** 2)
|
|
45
|
+
|
|
46
|
+
threshold = 4 / n
|
|
47
|
+
flagged_indices = np.where(cooks_d > threshold)[0]
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
"cooks_distance": cooks_d,
|
|
51
|
+
"leverage": leverage,
|
|
52
|
+
"threshold": threshold,
|
|
53
|
+
"flagged_indices": flagged_indices,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def cook_impact(x, y, flagged_indices, degree=2, n_points=50):
|
|
58
|
+
"""
|
|
59
|
+
Compare the Huber-fitted trend curve with and without the given
|
|
60
|
+
flagged observations, to quantify how much they actually pull the
|
|
61
|
+
curve.
|
|
62
|
+
"""
|
|
63
|
+
x = np.asarray(x, dtype=float)
|
|
64
|
+
y = np.asarray(y, dtype=float)
|
|
65
|
+
grid = np.linspace(x.min(), x.max(), n_points)
|
|
66
|
+
|
|
67
|
+
full_fit = fit_huber_trend(x, y, degree=degree)
|
|
68
|
+
full_pred = predict_trend(full_fit, grid)
|
|
69
|
+
|
|
70
|
+
mask = np.ones(len(x), dtype=bool)
|
|
71
|
+
mask[np.asarray(flagged_indices, dtype=int)] = False
|
|
72
|
+
|
|
73
|
+
if mask.sum() < degree + 2:
|
|
74
|
+
raise ValueError(
|
|
75
|
+
"Too few remaining observations to fit a comparison curve "
|
|
76
|
+
"after excluding flagged points."
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
reduced_fit = fit_huber_trend(x[mask], y[mask], degree=degree)
|
|
80
|
+
reduced_pred = predict_trend(reduced_fit, grid)
|
|
81
|
+
|
|
82
|
+
denom = np.abs(full_pred)
|
|
83
|
+
denom[denom == 0] = np.nan
|
|
84
|
+
pct_change = np.abs(full_pred - reduced_pred) / denom * 100
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
"grid": grid,
|
|
88
|
+
"with_flagged": full_pred,
|
|
89
|
+
"without_flagged": reduced_pred,
|
|
90
|
+
"pct_change": pct_change,
|
|
91
|
+
"median_pct_change": float(np.nanmedian(pct_change)),
|
|
92
|
+
"max_pct_change": float(np.nanmax(pct_change)),
|
|
93
|
+
"n_excluded": int((~mask).sum()),
|
|
94
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Quantify how much a fitted trend curve changes depending on which
|
|
3
|
+
fitting method (Huber, Tukey, or OLS) is used.
|
|
4
|
+
|
|
5
|
+
The central idea: a curve shape that is nearly identical across all
|
|
6
|
+
three methods is a more trustworthy conclusion than one that only
|
|
7
|
+
appears under a single method.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from .trend import fit_huber_trend, fit_tukey_trend, fit_ols_trend, predict_trend
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def model_stability_pct(x, y, degree=2, n_points=50):
|
|
16
|
+
"""
|
|
17
|
+
Fit Huber, Tukey, and OLS trends to the same (x, y) data, evaluate
|
|
18
|
+
all three on a common grid, and report how far apart they are as a
|
|
19
|
+
percentage of the average predicted value at each grid point.
|
|
20
|
+
"""
|
|
21
|
+
x = np.asarray(x, dtype=float)
|
|
22
|
+
y = np.asarray(y, dtype=float)
|
|
23
|
+
grid = np.linspace(x.min(), x.max(), n_points)
|
|
24
|
+
|
|
25
|
+
huber_fit = fit_huber_trend(x, y, degree=degree)
|
|
26
|
+
tukey_fit = fit_tukey_trend(x, y, degree=degree)
|
|
27
|
+
ols_fit = fit_ols_trend(x, y, degree=degree)
|
|
28
|
+
|
|
29
|
+
huber_pred = predict_trend(huber_fit, grid)
|
|
30
|
+
tukey_pred = predict_trend(tukey_fit, grid)
|
|
31
|
+
ols_pred = predict_trend(ols_fit, grid)
|
|
32
|
+
|
|
33
|
+
curves = np.vstack([huber_pred, tukey_pred, ols_pred])
|
|
34
|
+
spread = curves.max(axis=0) - curves.min(axis=0)
|
|
35
|
+
avg = np.abs(curves.mean(axis=0))
|
|
36
|
+
avg[avg == 0] = np.nan
|
|
37
|
+
pct_spread = spread / avg * 100
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
"grid": grid,
|
|
41
|
+
"huber": huber_pred,
|
|
42
|
+
"tukey": tukey_pred,
|
|
43
|
+
"ols": ols_pred,
|
|
44
|
+
"pct_spread": pct_spread,
|
|
45
|
+
"median_pct_diff": float(np.nanmedian(pct_spread)),
|
|
46
|
+
"p95_pct_diff": float(np.nanpercentile(pct_spread, 95)),
|
|
47
|
+
"max_pct_diff": float(np.nanmax(pct_spread)),
|
|
48
|
+
}
|
robustkit/core/trend.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Fit a trend curve -- y as a function of a single continuous x -- using
|
|
3
|
+
three different loss functions: Huber, Tukey biweight, and ordinary
|
|
4
|
+
least squares (OLS).
|
|
5
|
+
|
|
6
|
+
All three share the same polynomial design matrix, so their fitted
|
|
7
|
+
curves are directly comparable. OLS is included deliberately: if a
|
|
8
|
+
robust method and OLS agree closely, that agreement is itself useful
|
|
9
|
+
evidence that the conclusion is not driven by a handful of extreme
|
|
10
|
+
points (see robustkit.core.stability).
|
|
11
|
+
|
|
12
|
+
Note: statsmodels is only imported lazily, inside fit_tukey_trend and
|
|
13
|
+
the statsmodels branch of predict_trend. Huber and OLS fitting (via
|
|
14
|
+
scikit-learn) work without statsmodels installed at all.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
from sklearn.linear_model import HuberRegressor, LinearRegression
|
|
19
|
+
from sklearn.preprocessing import PolynomialFeatures
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _design_matrix(x, degree):
|
|
23
|
+
x = np.asarray(x, dtype=float).reshape(-1, 1)
|
|
24
|
+
poly = PolynomialFeatures(degree=degree, include_bias=False)
|
|
25
|
+
return poly.fit_transform(x), poly
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def fit_huber_trend(x, y, degree=2, epsilon=1.35, alpha=0.0001):
|
|
29
|
+
"""
|
|
30
|
+
Fit y ~ poly(x, degree) using Huber loss.
|
|
31
|
+
|
|
32
|
+
epsilon controls the point at which the loss switches from
|
|
33
|
+
quadratic to linear (smaller = more aggressive downweighting of
|
|
34
|
+
outliers; 1.35 is the common default, tuned for ~95% efficiency
|
|
35
|
+
under normal errors).
|
|
36
|
+
"""
|
|
37
|
+
X, poly = _design_matrix(x, degree)
|
|
38
|
+
model = HuberRegressor(epsilon=epsilon, alpha=alpha)
|
|
39
|
+
model.fit(X, y)
|
|
40
|
+
return {"model": model, "poly": poly, "kind": "sklearn"}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def fit_tukey_trend(x, y, degree=2, c=4.685):
|
|
44
|
+
"""
|
|
45
|
+
Fit y ~ poly(x, degree) using Tukey's biweight (redescending) loss
|
|
46
|
+
via statsmodels' robust linear model (RLM).
|
|
47
|
+
|
|
48
|
+
Unlike Huber, Tukey's loss fully suppresses the influence of very
|
|
49
|
+
extreme points rather than merely capping it -- useful when Huber
|
|
50
|
+
still seems pulled by a handful of severe outliers.
|
|
51
|
+
|
|
52
|
+
Requires statsmodels (imported lazily here).
|
|
53
|
+
"""
|
|
54
|
+
import statsmodels.api as sm
|
|
55
|
+
|
|
56
|
+
X, poly = _design_matrix(x, degree)
|
|
57
|
+
X_sm = sm.add_constant(X)
|
|
58
|
+
model = sm.RLM(np.asarray(y, dtype=float), X_sm, M=sm.robust.norms.TukeyBiweight(c=c)).fit()
|
|
59
|
+
return {"model": model, "poly": poly, "kind": "statsmodels"}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def fit_ols_trend(x, y, degree=2):
|
|
63
|
+
"""
|
|
64
|
+
Fit y ~ poly(x, degree) using ordinary least squares. Serves as a
|
|
65
|
+
non-robust reference point, not a method to be discarded.
|
|
66
|
+
"""
|
|
67
|
+
X, poly = _design_matrix(x, degree)
|
|
68
|
+
model = LinearRegression()
|
|
69
|
+
model.fit(X, y)
|
|
70
|
+
return {"model": model, "poly": poly, "kind": "sklearn"}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def predict_trend(fit, x_new):
|
|
74
|
+
"""
|
|
75
|
+
Predict y for new x values from a fit dict returned by
|
|
76
|
+
fit_huber_trend / fit_tukey_trend / fit_ols_trend.
|
|
77
|
+
"""
|
|
78
|
+
x_new = np.asarray(x_new, dtype=float).reshape(-1, 1)
|
|
79
|
+
X_new = fit["poly"].transform(x_new)
|
|
80
|
+
|
|
81
|
+
if fit["kind"] == "statsmodels":
|
|
82
|
+
import statsmodels.api as sm
|
|
83
|
+
X_new = sm.add_constant(X_new, has_constant="add")
|
|
84
|
+
return np.asarray(fit["model"].predict(X_new))
|
|
85
|
+
|
|
86
|
+
return np.asarray(fit["model"].predict(X_new))
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Quantify uncertainty in a fitted trend curve (or any statistic derived
|
|
3
|
+
from (x, y)) via bootstrap resampling.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
from scipy import stats
|
|
8
|
+
|
|
9
|
+
from .trend import fit_huber_trend, predict_trend
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def bootstrap_band(x, y, degree=2, n_boot=500, ci=95, n_points=50, seed=0):
|
|
13
|
+
rng = np.random.default_rng(seed)
|
|
14
|
+
x = np.asarray(x, dtype=float)
|
|
15
|
+
y = np.asarray(y, dtype=float)
|
|
16
|
+
n = len(x)
|
|
17
|
+
grid = np.linspace(x.min(), x.max(), n_points)
|
|
18
|
+
|
|
19
|
+
preds = np.empty((n_boot, n_points))
|
|
20
|
+
for b in range(n_boot):
|
|
21
|
+
idx = rng.integers(0, n, n)
|
|
22
|
+
fit = fit_huber_trend(x[idx], y[idx], degree=degree)
|
|
23
|
+
preds[b] = predict_trend(fit, grid)
|
|
24
|
+
|
|
25
|
+
lower_pct = (100 - ci) / 2
|
|
26
|
+
upper_pct = 100 - lower_pct
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
"grid": grid,
|
|
30
|
+
"lower": np.percentile(preds, lower_pct, axis=0),
|
|
31
|
+
"median": np.percentile(preds, 50, axis=0),
|
|
32
|
+
"upper": np.percentile(preds, upper_pct, axis=0),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def bca_bootstrap_ci(x, y, statistic_fn, n_boot=1000, ci=95, seed=0):
|
|
37
|
+
rng = np.random.default_rng(seed)
|
|
38
|
+
x = np.asarray(x, dtype=float)
|
|
39
|
+
y = np.asarray(y, dtype=float)
|
|
40
|
+
n = len(x)
|
|
41
|
+
|
|
42
|
+
theta_hat = statistic_fn(x, y)
|
|
43
|
+
|
|
44
|
+
boot_thetas = np.empty(n_boot)
|
|
45
|
+
for b in range(n_boot):
|
|
46
|
+
idx = rng.integers(0, n, n)
|
|
47
|
+
boot_thetas[b] = statistic_fn(x[idx], y[idx])
|
|
48
|
+
|
|
49
|
+
prop_less = np.mean(boot_thetas < theta_hat)
|
|
50
|
+
prop_less = np.clip(prop_less, 1e-6, 1 - 1e-6)
|
|
51
|
+
z0 = stats.norm.ppf(prop_less)
|
|
52
|
+
|
|
53
|
+
jack_thetas = np.empty(n)
|
|
54
|
+
for i in range(n):
|
|
55
|
+
mask = np.ones(n, dtype=bool)
|
|
56
|
+
mask[i] = False
|
|
57
|
+
jack_thetas[i] = statistic_fn(x[mask], y[mask])
|
|
58
|
+
|
|
59
|
+
jack_mean = jack_thetas.mean()
|
|
60
|
+
num = np.sum((jack_mean - jack_thetas) ** 3)
|
|
61
|
+
den = 6.0 * (np.sum((jack_mean - jack_thetas) ** 2) ** 1.5)
|
|
62
|
+
a = num / den if den != 0 else 0.0
|
|
63
|
+
|
|
64
|
+
alpha = (100 - ci) / 100 / 2
|
|
65
|
+
z_lo = stats.norm.ppf(alpha)
|
|
66
|
+
z_hi = stats.norm.ppf(1 - alpha)
|
|
67
|
+
|
|
68
|
+
def _adjust(z):
|
|
69
|
+
denom = 1 - a * (z0 + z)
|
|
70
|
+
adjusted_z = z0 + (z0 + z) / denom if denom != 0 else z0
|
|
71
|
+
return stats.norm.cdf(adjusted_z)
|
|
72
|
+
|
|
73
|
+
lo_pct = np.clip(_adjust(z_lo) * 100, 0, 100)
|
|
74
|
+
hi_pct = np.clip(_adjust(z_hi) * 100, 0, 100)
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
"estimate": theta_hat,
|
|
78
|
+
"lower": float(np.percentile(boot_thetas, lo_pct)),
|
|
79
|
+
"upper": float(np.percentile(boot_thetas, hi_pct)),
|
|
80
|
+
"z0": float(z0),
|
|
81
|
+
"a": float(a),
|
|
82
|
+
"boot_distribution": boot_thetas,
|
|
83
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shannon entropy for categorical (or already-discretized) variables.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def entropy(series):
|
|
10
|
+
"""
|
|
11
|
+
Shannon entropy in bits of a pandas Series, treating its values as
|
|
12
|
+
categorical. For continuous data, discretize (bin) it before
|
|
13
|
+
calling this -- entropy on raw continuous values is not meaningful
|
|
14
|
+
here.
|
|
15
|
+
"""
|
|
16
|
+
counts = pd.Series(series).value_counts(normalize=True)
|
|
17
|
+
probs = counts.to_numpy()
|
|
18
|
+
probs = probs[probs > 0]
|
|
19
|
+
return float(-np.sum(probs * np.log2(probs)))
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Rank features by mutual information with a target, normalized by each
|
|
3
|
+
feature's own entropy ("information efficiency" -- how much of a
|
|
4
|
+
feature's information content is actually being used to predict the
|
|
5
|
+
target).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
from sklearn.feature_selection import mutual_info_classif, mutual_info_regression
|
|
10
|
+
|
|
11
|
+
from .entropy import entropy
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def prepare_features(X):
|
|
15
|
+
"""
|
|
16
|
+
Minimal default preprocessing: median-impute numeric columns,
|
|
17
|
+
fill missing categoricals with an explicit "Missing" category,
|
|
18
|
+
then integer-encode all categoricals. Intended as a reasonable
|
|
19
|
+
default for mutual information estimation, not a general-purpose
|
|
20
|
+
preprocessing pipeline.
|
|
21
|
+
"""
|
|
22
|
+
X = X.copy()
|
|
23
|
+
for col in X.columns:
|
|
24
|
+
if pd.api.types.is_numeric_dtype(X[col]):
|
|
25
|
+
X[col] = X[col].fillna(X[col].median())
|
|
26
|
+
else:
|
|
27
|
+
X[col] = X[col].fillna("Missing").astype("category").cat.codes
|
|
28
|
+
return X
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def information_efficiency(mutual_information, entropy_bits):
|
|
32
|
+
"""
|
|
33
|
+
Mutual information per bit of the feature's own entropy. Answers:
|
|
34
|
+
"of everything this feature could tell us, how much is actually
|
|
35
|
+
being used to predict the target?" A high-cardinality feature can
|
|
36
|
+
have high raw mutual information while still being inefficient --
|
|
37
|
+
most of its information content goes unused.
|
|
38
|
+
"""
|
|
39
|
+
if entropy_bits <= 0:
|
|
40
|
+
return 0.0
|
|
41
|
+
return float(mutual_information / entropy_bits)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _target_is_continuous(y, max_categories=20):
|
|
45
|
+
"""
|
|
46
|
+
Heuristic: numeric dtype with more than `max_categories` distinct
|
|
47
|
+
values is treated as continuous; everything else (non-numeric, or
|
|
48
|
+
numeric with few distinct values -- e.g. an integer-coded class
|
|
49
|
+
label) is treated as categorical.
|
|
50
|
+
"""
|
|
51
|
+
return pd.api.types.is_numeric_dtype(y) and y.nunique() > max_categories
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def rank_features(df, target, seed=0):
|
|
55
|
+
"""
|
|
56
|
+
Rank every column in df (except target) by mutual information with
|
|
57
|
+
target and by information efficiency.
|
|
58
|
+
|
|
59
|
+
Automatically detects whether target should be treated as
|
|
60
|
+
continuous (mutual_info_regression) or categorical
|
|
61
|
+
(mutual_info_classif) via _target_is_continuous.
|
|
62
|
+
"""
|
|
63
|
+
X = df.drop(columns=[target])
|
|
64
|
+
y = df[target]
|
|
65
|
+
X_enc = prepare_features(X)
|
|
66
|
+
|
|
67
|
+
if _target_is_continuous(y):
|
|
68
|
+
mi = mutual_info_regression(X_enc, y, random_state=seed)
|
|
69
|
+
else:
|
|
70
|
+
y_enc = y.astype("category").cat.codes
|
|
71
|
+
mi = mutual_info_classif(X_enc, y_enc, random_state=seed)
|
|
72
|
+
|
|
73
|
+
rows = []
|
|
74
|
+
for col, mi_value in zip(X.columns, mi):
|
|
75
|
+
bits = entropy(X[col])
|
|
76
|
+
rows.append({
|
|
77
|
+
"feature": col,
|
|
78
|
+
"mutual_information": float(mi_value),
|
|
79
|
+
"entropy_bits": bits,
|
|
80
|
+
"information_efficiency": information_efficiency(mi_value, bits),
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
pd.DataFrame(rows)
|
|
85
|
+
.sort_values("information_efficiency", ascending=False)
|
|
86
|
+
.reset_index(drop=True)
|
|
87
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Basic dataset profiling: a quick orientation before running any
|
|
3
|
+
information-theoretic analysis.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def profile(df, target=None):
|
|
8
|
+
info = {
|
|
9
|
+
"n_rows": len(df),
|
|
10
|
+
"n_columns": df.shape[1],
|
|
11
|
+
"columns": list(df.columns),
|
|
12
|
+
"missing_per_column": {k: int(v) for k, v in df.isna().sum().to_dict().items()},
|
|
13
|
+
}
|
|
14
|
+
if target is not None:
|
|
15
|
+
info["target"] = target
|
|
16
|
+
info["target_dtype"] = str(df[target].dtype)
|
|
17
|
+
info["target_n_unique"] = int(df[target].nunique())
|
|
18
|
+
return info
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def print_profile(df, target=None):
|
|
22
|
+
p = profile(df, target=target)
|
|
23
|
+
print(f"Rows: {p['n_rows']}, Columns: {p['n_columns']}")
|
|
24
|
+
if target is not None:
|
|
25
|
+
print(f"Target: {p['target']} (dtype={p['target_dtype']}, unique={p['target_n_unique']})")
|
|
26
|
+
missing = {k: v for k, v in p["missing_per_column"].items() if v > 0}
|
|
27
|
+
if missing:
|
|
28
|
+
print("Missing values:")
|
|
29
|
+
for col, n in missing.items():
|
|
30
|
+
print(f" {col}: {n}")
|
|
31
|
+
else:
|
|
32
|
+
print("No missing values.")
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Classify features into four quadrants based on mutual information and
|
|
3
|
+
information efficiency.
|
|
4
|
+
|
|
5
|
+
Both quadrant_report() and visualization.plot_feature_space() route
|
|
6
|
+
through this module's thresholding logic, so a feature can never be
|
|
7
|
+
labeled differently by the table and the plot -- a bug present in an
|
|
8
|
+
earlier version of this code, where the plot used a fixed 75th
|
|
9
|
+
percentile cutoff while the report used the median.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .mutual_info import rank_features
|
|
13
|
+
|
|
14
|
+
QUADRANT_LABELS = {
|
|
15
|
+
"star": "Star", # high MI, high efficiency
|
|
16
|
+
"power": "Predictive", # high MI, low efficiency: informative but "expensive"
|
|
17
|
+
"efficient": "Efficient", # low MI, high efficiency: cheap but limited ceiling
|
|
18
|
+
"weak": "Weak", # low MI, low efficiency
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def quadrant_report(df=None, target=None, ranking=None, mi_threshold="median", eff_threshold="median"):
|
|
23
|
+
"""
|
|
24
|
+
Classify features into four quadrants of (mutual information,
|
|
25
|
+
information efficiency) space.
|
|
26
|
+
|
|
27
|
+
Provide either a precomputed `ranking` (output of rank_features)
|
|
28
|
+
or a `df`/`target` pair to compute it internally.
|
|
29
|
+
|
|
30
|
+
mi_threshold / eff_threshold: "median" (default) or a quantile in
|
|
31
|
+
(0, 1) used as the cutoff for "high" on each axis. Both quadrant
|
|
32
|
+
thresholds are stored in the returned DataFrame's `.attrs` for
|
|
33
|
+
inspection or reuse (e.g. by plot_feature_space).
|
|
34
|
+
"""
|
|
35
|
+
if ranking is None:
|
|
36
|
+
if df is None or target is None:
|
|
37
|
+
raise ValueError("Provide either `ranking` or both `df` and `target`.")
|
|
38
|
+
ranking = rank_features(df, target=target)
|
|
39
|
+
else:
|
|
40
|
+
ranking = ranking.copy()
|
|
41
|
+
|
|
42
|
+
mi_cut = (
|
|
43
|
+
ranking["mutual_information"].median()
|
|
44
|
+
if mi_threshold == "median"
|
|
45
|
+
else ranking["mutual_information"].quantile(mi_threshold)
|
|
46
|
+
)
|
|
47
|
+
eff_cut = (
|
|
48
|
+
ranking["information_efficiency"].median()
|
|
49
|
+
if eff_threshold == "median"
|
|
50
|
+
else ranking["information_efficiency"].quantile(eff_threshold)
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def _label(row):
|
|
54
|
+
high_mi = row["mutual_information"] >= mi_cut
|
|
55
|
+
high_eff = row["information_efficiency"] >= eff_cut
|
|
56
|
+
if high_mi and high_eff:
|
|
57
|
+
return "star"
|
|
58
|
+
if high_mi:
|
|
59
|
+
return "power"
|
|
60
|
+
if high_eff:
|
|
61
|
+
return "efficient"
|
|
62
|
+
return "weak"
|
|
63
|
+
|
|
64
|
+
ranking["quadrant"] = ranking.apply(_label, axis=1)
|
|
65
|
+
ranking.attrs["mi_threshold"] = mi_cut
|
|
66
|
+
ranking.attrs["eff_threshold"] = eff_cut
|
|
67
|
+
return ranking
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Visualize features in information space (efficiency vs. mutual
|
|
3
|
+
information).
|
|
4
|
+
|
|
5
|
+
plot_feature_space always computes (or accepts) a quadrant-labeled
|
|
6
|
+
ranking via quadrants.quadrant_report -- it never applies its own,
|
|
7
|
+
separate threshold. This guarantees the plot and quadrant_report() can
|
|
8
|
+
never disagree about which quadrant a feature falls into.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .quadrants import quadrant_report, QUADRANT_LABELS
|
|
12
|
+
|
|
13
|
+
_QUADRANT_COLORS = {
|
|
14
|
+
"star": "green",
|
|
15
|
+
"power": "steelblue",
|
|
16
|
+
"efficient": "darkorange",
|
|
17
|
+
"weak": "gray",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def plot_feature_space(df=None, target=None, ranking=None, annotate=True, figsize=(10, 7), ax=None):
|
|
22
|
+
"""
|
|
23
|
+
Scatter plot of features in information space: x = information
|
|
24
|
+
efficiency, y = mutual information, bubble size = entropy (bits),
|
|
25
|
+
color = quadrant.
|
|
26
|
+
|
|
27
|
+
Provide either a precomputed `ranking` (ideally the output of
|
|
28
|
+
quadrant_report, so quadrant labels are already attached) or a
|
|
29
|
+
`df`/`target` pair to compute everything internally.
|
|
30
|
+
|
|
31
|
+
Returns the quadrant-labeled ranking DataFrame (so you can inspect
|
|
32
|
+
or reuse it), regardless of whether a plot was drawn.
|
|
33
|
+
"""
|
|
34
|
+
import matplotlib.pyplot as plt
|
|
35
|
+
from matplotlib.lines import Line2D
|
|
36
|
+
|
|
37
|
+
if ranking is None or "quadrant" not in ranking.columns:
|
|
38
|
+
ranking = quadrant_report(df=df, target=target, ranking=ranking)
|
|
39
|
+
|
|
40
|
+
colors = ranking["quadrant"].map(_QUADRANT_COLORS)
|
|
41
|
+
sizes = ranking["entropy_bits"] * 60 + 20
|
|
42
|
+
|
|
43
|
+
created_fig = ax is None
|
|
44
|
+
if created_fig:
|
|
45
|
+
fig, ax = plt.subplots(figsize=figsize)
|
|
46
|
+
|
|
47
|
+
ax.scatter(
|
|
48
|
+
ranking["information_efficiency"], ranking["mutual_information"],
|
|
49
|
+
s=sizes, c=colors, alpha=0.7, edgecolors="black", linewidths=0.5,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if annotate:
|
|
53
|
+
for _, row in ranking.iterrows():
|
|
54
|
+
ax.annotate(
|
|
55
|
+
row["feature"],
|
|
56
|
+
(row["information_efficiency"], row["mutual_information"]),
|
|
57
|
+
fontsize=8, xytext=(4, 4), textcoords="offset points",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
legend_elements = [
|
|
61
|
+
Line2D([0], [0], marker="o", color="w", label=label,
|
|
62
|
+
markerfacecolor=_QUADRANT_COLORS[quadrant], markersize=10)
|
|
63
|
+
for quadrant, label in QUADRANT_LABELS.items()
|
|
64
|
+
]
|
|
65
|
+
ax.legend(handles=legend_elements, loc="best")
|
|
66
|
+
ax.set_xlabel("Information Efficiency (MI per bit of feature entropy)")
|
|
67
|
+
ax.set_ylabel("Mutual Information")
|
|
68
|
+
ax.set_title("Feature Information Space")
|
|
69
|
+
ax.grid(True, alpha=0.3)
|
|
70
|
+
|
|
71
|
+
if created_fig:
|
|
72
|
+
fig.tight_layout()
|
|
73
|
+
|
|
74
|
+
return ranking
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def feature_map(df, target, **kwargs):
|
|
78
|
+
"""Convenience wrapper: rank features and plot them in one call."""
|
|
79
|
+
return plot_feature_space(df=df, target=target, **kwargs)
|
|
File without changes
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Run any robustkit.core analysis function independently within each
|
|
3
|
+
segment of a dataset, and collect the scalar summary results into a
|
|
4
|
+
single report table.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _is_reportable_scalar(value):
|
|
12
|
+
if value is None:
|
|
13
|
+
return True
|
|
14
|
+
if isinstance(value, (dict, list, tuple, set, np.ndarray)):
|
|
15
|
+
return False
|
|
16
|
+
return isinstance(value, (int, float, str, bool, np.integer, np.floating, np.bool_))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def apply_by_segment(df, segment_col, x_col, y_col, analysis_fn, min_points=5, **kwargs):
|
|
20
|
+
rows = []
|
|
21
|
+
|
|
22
|
+
for segment_value, group in df.groupby(segment_col, observed=True):
|
|
23
|
+
row = {"segment": segment_value, "n": len(group)}
|
|
24
|
+
|
|
25
|
+
if len(group) < min_points:
|
|
26
|
+
row["skipped"] = True
|
|
27
|
+
row["reason"] = f"fewer than {min_points} points"
|
|
28
|
+
rows.append(row)
|
|
29
|
+
continue
|
|
30
|
+
|
|
31
|
+
row["skipped"] = False
|
|
32
|
+
|
|
33
|
+
x = group[x_col].to_numpy(dtype=float)
|
|
34
|
+
y = group[y_col].to_numpy(dtype=float)
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
result = analysis_fn(x, y, **kwargs)
|
|
38
|
+
except Exception as exc: # noqa: BLE001
|
|
39
|
+
row["error"] = str(exc)
|
|
40
|
+
rows.append(row)
|
|
41
|
+
continue
|
|
42
|
+
|
|
43
|
+
for key, value in result.items():
|
|
44
|
+
if _is_reportable_scalar(value):
|
|
45
|
+
row[key] = value
|
|
46
|
+
|
|
47
|
+
rows.append(row)
|
|
48
|
+
|
|
49
|
+
return pd.DataFrame(rows)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hierarchical segmentation with a minimum-size fallback.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def hierarchical_segment(df, hierarchy, min_size=20):
|
|
9
|
+
df = df.copy()
|
|
10
|
+
df["segment_id"] = pd.NA
|
|
11
|
+
df["segment_level"] = pd.NA
|
|
12
|
+
|
|
13
|
+
remaining_idx = df.index
|
|
14
|
+
|
|
15
|
+
for level_idx, cols in enumerate(hierarchy):
|
|
16
|
+
if len(remaining_idx) == 0:
|
|
17
|
+
break
|
|
18
|
+
|
|
19
|
+
subset = df.loc[remaining_idx]
|
|
20
|
+
sizes = subset.groupby(cols, observed=True).size()
|
|
21
|
+
valid_groups = sizes[sizes >= min_size].index
|
|
22
|
+
|
|
23
|
+
if len(valid_groups) == 0:
|
|
24
|
+
continue
|
|
25
|
+
|
|
26
|
+
group_keys = subset[cols].apply(tuple, axis=1)
|
|
27
|
+
|
|
28
|
+
for group_key in valid_groups:
|
|
29
|
+
if not isinstance(group_key, tuple):
|
|
30
|
+
group_key = (group_key,)
|
|
31
|
+
mask = group_keys == group_key
|
|
32
|
+
idx = subset.index[mask]
|
|
33
|
+
|
|
34
|
+
label = "_".join(str(v) for v in group_key)
|
|
35
|
+
df.loc[idx, "segment_id"] = label
|
|
36
|
+
df.loc[idx, "segment_level"] = level_idx
|
|
37
|
+
|
|
38
|
+
still_unassigned = df.loc[remaining_idx, "segment_id"].isna()
|
|
39
|
+
remaining_idx = still_unassigned[still_unassigned].index
|
|
40
|
+
|
|
41
|
+
if len(remaining_idx) > 0:
|
|
42
|
+
df.loc[remaining_idx, "segment_id"] = "ALL"
|
|
43
|
+
df.loc[remaining_idx, "segment_level"] = len(hierarchy)
|
|
44
|
+
|
|
45
|
+
return df
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def segment_sizes(df, segment_col="segment_id"):
|
|
49
|
+
return df.groupby(segment_col, observed=True).size().sort_values(ascending=False)
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: robustkit
|
|
3
|
+
Version: 0.0.1
|
|
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
|
+
Requires-Python: >=3.10
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Requires-Dist: numpy>=1.24
|
|
32
|
+
Requires-Dist: pandas>=2.0
|
|
33
|
+
Requires-Dist: scikit-learn>=1.3
|
|
34
|
+
Requires-Dist: statsmodels>=0.14
|
|
35
|
+
Requires-Dist: scipy>=1.10
|
|
36
|
+
Requires-Dist: matplotlib>=3.7
|
|
37
|
+
Provides-Extra: dev
|
|
38
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
39
|
+
Dynamic: license-file
|
|
40
|
+
|
|
41
|
+
# robustkit
|
|
42
|
+
|
|
43
|
+
> ⚠️ **Under active development.** This is an early placeholder release
|
|
44
|
+
> to claim the package name on PyPI. The API is incomplete and may
|
|
45
|
+
> change without notice. Not yet recommended for production use.
|
|
46
|
+
|
|
47
|
+
Practical tools for robust analysis of a single continuous relationship:
|
|
48
|
+
y as a function of one continuous x.
|
|
49
|
+
|
|
50
|
+
The guiding idea: **a conclusion that survives multiple fitting methods
|
|
51
|
+
is more trustworthy than one that only holds under a single model.**
|
|
52
|
+
`robustkit` makes it easy to compare Huber, Tukey biweight, and OLS
|
|
53
|
+
fits side by side, identify and quantify the influence of individual
|
|
54
|
+
observations, and get honest, bias-corrected uncertainty estimates.
|
|
55
|
+
|
|
56
|
+
## Status
|
|
57
|
+
|
|
58
|
+
`robustkit.core` (trend fitting, stability, diagnostics, uncertainty,
|
|
59
|
+
consistency checks), `robustkit.segmentation` (hierarchical grouping,
|
|
60
|
+
per-segment analysis), and `robustkit.information` (mutual-information
|
|
61
|
+
feature ranking) are stable and tested. A more advanced
|
|
62
|
+
information-theoretic pairing layer (conditional MI for a second
|
|
63
|
+
variable, synergy/redundancy scoring) is planned but not yet included.
|
|
64
|
+
|
|
65
|
+
## Installation
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
git clone https://github.com/<your-username>/robustkit.git
|
|
69
|
+
cd robustkit
|
|
70
|
+
pip install -e ".[dev]"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Quickstart
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
import numpy as np
|
|
77
|
+
from robustkit import (
|
|
78
|
+
fit_huber_trend, fit_tukey_trend, predict_trend,
|
|
79
|
+
model_stability_pct, cooks_diagnostic, cook_impact,
|
|
80
|
+
bootstrap_band, bca_bootstrap_ci,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# x: a single continuous predictor, y: a single continuous outcome
|
|
84
|
+
x = np.random.default_rng(0).uniform(20, 60, 200)
|
|
85
|
+
y = 1000 + 50 * x - 0.4 * x**2 + np.random.default_rng(1).normal(0, 500, 200)
|
|
86
|
+
|
|
87
|
+
fit = fit_huber_trend(x, y, degree=2)
|
|
88
|
+
y_pred = predict_trend(fit, x_new=[30, 40, 50])
|
|
89
|
+
|
|
90
|
+
stability = model_stability_pct(x, y)
|
|
91
|
+
print("Median % spread between Huber/Tukey/OLS:", stability["median_pct_diff"])
|
|
92
|
+
|
|
93
|
+
diag = cooks_diagnostic(x, y)
|
|
94
|
+
impact = cook_impact(x, y, diag["flagged_indices"])
|
|
95
|
+
print("Median % change in curve if flagged points removed:", impact["median_pct_change"])
|
|
96
|
+
|
|
97
|
+
band = bootstrap_band(x, y)
|
|
98
|
+
ci = bca_bootstrap_ci(x, y, statistic_fn=lambda x_, y_: np.median(y_))
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
See `examples/quickstart_tutorial.py` for a complete, runnable walkthrough.
|
|
102
|
+
|
|
103
|
+
## Segmentation
|
|
104
|
+
|
|
105
|
+
Run any `robustkit.core` analysis independently across subgroups of a
|
|
106
|
+
larger dataset, with automatic fallback to coarser groupings when a
|
|
107
|
+
finer one is too small to analyze reliably:
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
from robustkit import hierarchical_segment, apply_by_segment, model_stability_pct
|
|
111
|
+
|
|
112
|
+
hierarchy = [["department", "level", "status"], ["level", "status"], ["status"]]
|
|
113
|
+
segmented = hierarchical_segment(df, hierarchy, min_size=20)
|
|
114
|
+
|
|
115
|
+
report = apply_by_segment(
|
|
116
|
+
segmented, segment_col="segment_id", x_col="age", y_col="value",
|
|
117
|
+
analysis_fn=model_stability_pct,
|
|
118
|
+
)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`apply_by_segment` works with any function shaped like
|
|
122
|
+
`analysis_fn(x, y, **kwargs) -> dict` -- built-in ones
|
|
123
|
+
(`model_stability_pct`, `cook_impact`, `bca_bootstrap_ci`, ...) or your
|
|
124
|
+
own. Only scalar values in the returned dict end up in the report
|
|
125
|
+
table; segments below `min_points` are skipped rather than causing an
|
|
126
|
+
error.
|
|
127
|
+
|
|
128
|
+
## Feature ranking (information)
|
|
129
|
+
|
|
130
|
+
Rank features by mutual information with a target, normalized by each
|
|
131
|
+
feature's own entropy, and classify them into four quadrants:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from robustkit import rank_features, quadrant_report, plot_feature_space
|
|
135
|
+
|
|
136
|
+
ranking = rank_features(df, target="value")
|
|
137
|
+
report = quadrant_report(df, target="value") # adds a `quadrant` column
|
|
138
|
+
plot_feature_space(df, target="value") # same quadrants, visualized
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`quadrant_report` and `plot_feature_space` always agree on quadrant
|
|
142
|
+
assignment -- both route through the same thresholding logic.
|
|
143
|
+
|
|
144
|
+
**Caveat:** default thresholds are the *median* mutual information /
|
|
145
|
+
efficiency across the ranked features. With only a handful of
|
|
146
|
+
features, this can put a genuinely weak feature in the same "high"
|
|
147
|
+
half as a strong one, since roughly half of any list sits above its
|
|
148
|
+
own median regardless of how large the actual gap is. Median
|
|
149
|
+
thresholding becomes meaningful with a reasonably large feature set;
|
|
150
|
+
for a handful of candidates, read the raw `mutual_information` /
|
|
151
|
+
`information_efficiency` values directly rather than relying on the
|
|
152
|
+
quadrant label alone.
|
|
153
|
+
|
|
154
|
+
See `examples/information_tutorial.py` for a complete walkthrough.
|
|
155
|
+
|
|
156
|
+
## Design principles
|
|
157
|
+
|
|
158
|
+
- **One continuous x, one continuous y** at the core. This keeps every
|
|
159
|
+
function's output visually and numerically interpretable (a curve
|
|
160
|
+
you can plot, a band you can read).
|
|
161
|
+
- **Diagnosis and action are separate steps.** `cooks_diagnostic`
|
|
162
|
+
flags candidates; `cook_impact` tells you whether removing them
|
|
163
|
+
actually changes anything.
|
|
164
|
+
- **OLS is a reference point, not the enemy.** Comparing robust fits
|
|
165
|
+
against OLS is how you know whether robustness mattered at all.
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
MIT -- see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
robustkit/__init__.py,sha256=fdEzGgsnzzOm75nHouJV-iao8WlJ_V9gMMeaQ2WEWXM,1809
|
|
2
|
+
robustkit/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
robustkit/core/consistency.py,sha256=AoOrntyBUUbUXJ8u9cVXScT2q0rjac-FVfBvFdCyRs0,913
|
|
4
|
+
robustkit/core/diagnostics.py,sha256=AbpNRmQKZeacQPXnUI4YakBpryf_HlqsBTVhT2atKH0,2881
|
|
5
|
+
robustkit/core/stability.py,sha256=z7jb4G0Fr193etZxZuMSSasBNdxQY-7RY3Tt2WL_Y0U,1640
|
|
6
|
+
robustkit/core/trend.py,sha256=KajZVanRlxz1hXyBVglqe5MXLweKusfqM7nZ3zjzbf4,3069
|
|
7
|
+
robustkit/core/uncertainty.py,sha256=U4VJd1vq68wVZtfqRTs28KMMxqyt52XXnJNNJzGbI4c,2458
|
|
8
|
+
robustkit/information/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
robustkit/information/entropy.py,sha256=hPgrDylHqNTu5lFKccqU8ek2f2WwArP3WCiSmF1OlSM,541
|
|
10
|
+
robustkit/information/mutual_info.py,sha256=VvhtXHxjcBoWvodBMK-BMXtfQZGlL2Np8OmCUPW2i5Q,2940
|
|
11
|
+
robustkit/information/profile.py,sha256=y0iMTdV7yypiAL8HRBJ1FfeDMzLaVKAaIlFx-D_Fz4A,1033
|
|
12
|
+
robustkit/information/quadrants.py,sha256=gNuG2uPhg5-wteKKzIujGcvT7un17nNHePYADTts6fc,2453
|
|
13
|
+
robustkit/information/visualization.py,sha256=9LvP9CvQxz9R5xGj0qWdG1bm5dHDVy4Jc_ceVl_01i8,2693
|
|
14
|
+
robustkit/segmentation/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
+
robustkit/segmentation/apply.py,sha256=HaIz7HUlYMUegpvtJ7eCuQ8SPGh9UCZHG8vA2Ec_7aA,1379
|
|
16
|
+
robustkit/segmentation/hierarchy.py,sha256=9ID64UeBvxdj8QdzbZ-zVl5BhqeJExUpVa3mX7n_Hgs,1423
|
|
17
|
+
robustkit-0.0.1.dist-info/licenses/LICENSE,sha256=OWkkHKeAsP8EtJihCUcc2EzJo2SCq6w8OVsWrkdX9qA,1073
|
|
18
|
+
robustkit-0.0.1.dist-info/METADATA,sha256=aDAdVLVynAKXr7BnBFe-qtgn2zMDVEHa9fLJh89ntzs,6682
|
|
19
|
+
robustkit-0.0.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
20
|
+
robustkit-0.0.1.dist-info/top_level.txt,sha256=4p1ktr2dT_27qBBADwfjoR0wdzuajfLB9RkQJEqF_TA,10
|
|
21
|
+
robustkit-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mikael Lundqvist
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
robustkit
|