tide-binning 0.6.0__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.
tide/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """TIDE - Time Invariant Discretization Engine.
2
+
3
+ Time-stable binning of mixed-type features for credit scorecards.
4
+ """
5
+
6
+ from ._estimator import TIDE
7
+
8
+ __version__ = "0.6.0"
9
+
10
+ __all__ = ["TIDE", "__version__"]
tide/_bins.py ADDED
@@ -0,0 +1,178 @@
1
+ """The bins format: validation, row assignment and labels.
2
+
3
+ A bins dict looks like::
4
+
5
+ {"trend": "pos",
6
+ "bins": {0: {(-np.inf, 0.34): "cont", "special_category": "cat"},
7
+ 1: {(0.34, 0.65): "cont"},
8
+ 2: {(0.65, np.inf): "cont"},
9
+ 3: {None: "missing"}}}
10
+
11
+ Continuous intervals are ``(a, b]`` for ``trend="pos"`` and ``[a, b)`` for
12
+ ``trend="neg"``. Infinite bounds are always inclusive. ``None`` is the key for
13
+ NaN / None / pd.NA / NaT.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import numbers
19
+ from dataclasses import dataclass
20
+
21
+ import numpy as np
22
+
23
+ from ._parse import ParsedColumn, normalize_key
24
+
25
+ __all__ = ["CompiledBins", "bin_label", "compile_bins", "special_keys", "validate_bins"]
26
+
27
+ ATOM_TYPES = ("cont", "cat", "missing")
28
+
29
+
30
+ def validate_bins(bins: dict, feature: str) -> dict:
31
+ """Check a user bins dict and return a normalized copy."""
32
+ where = f"user_bins[{feature!r}]"
33
+ if not isinstance(bins, dict) or "bins" not in bins or not isinstance(bins["bins"], dict):
34
+ raise ValueError(f"{where} must be a dict with a 'bins' dict")
35
+ if not bins["bins"]:
36
+ raise ValueError(f"{where} has no bins")
37
+ out: dict = {}
38
+ has_cont = False
39
+ for idx, atoms in bins["bins"].items():
40
+ if not isinstance(idx, (int, np.integer)) or isinstance(idx, bool):
41
+ raise ValueError(f"{where}: bin index {idx!r} must be an int")
42
+ if not isinstance(atoms, dict) or not atoms:
43
+ raise ValueError(f"{where}: bin {idx} must be a non-empty dict of atom -> type")
44
+ norm = {}
45
+ for atom, kind in atoms.items():
46
+ if kind not in ATOM_TYPES:
47
+ raise ValueError(f"{where}: bin {idx} has atom type {kind!r}; use {ATOM_TYPES}")
48
+ if kind == "cont":
49
+ ok = (
50
+ isinstance(atom, tuple)
51
+ and len(atom) == 2
52
+ and all(isinstance(v, numbers.Real) and not isinstance(v, bool) for v in atom)
53
+ and not np.isnan(atom[0])
54
+ and not np.isnan(atom[1])
55
+ )
56
+ if not ok or not atom[0] < atom[1]:
57
+ raise ValueError(
58
+ f"{where}: bin {idx} has invalid interval {atom!r}; use (a, b) with a < b"
59
+ )
60
+ has_cont = True
61
+ norm[(float(atom[0]), float(atom[1]))] = "cont"
62
+ else:
63
+ norm[normalize_key(atom)] = kind
64
+ out[int(idx)] = norm
65
+ trend = bins.get("trend")
66
+ if has_cont and trend not in ("pos", "neg"):
67
+ raise ValueError(f"{where} has continuous intervals, so 'trend' must be 'pos' or 'neg'")
68
+ result = {"trend": trend if trend in ("pos", "neg") else "pos", "bins": out}
69
+ compile_bins(result, feature) # checks overlaps and duplicate keys
70
+ return result
71
+
72
+
73
+ def special_keys(bins: dict) -> tuple[list, list]:
74
+ """Numeric keys typed 'cat' / 'missing' in a bins dict (e.g. -999)."""
75
+ cat, miss = [], []
76
+ for atoms in bins["bins"].values():
77
+ for atom, kind in atoms.items():
78
+ if kind != "cont" and isinstance(atom, numbers.Real) and not isinstance(atom, bool):
79
+ (cat if kind == "cat" else miss).append(atom)
80
+ return cat, miss
81
+
82
+
83
+ @dataclass
84
+ class CompiledBins:
85
+ """A bins dict in a form ready for vectorized assignment."""
86
+
87
+ trend: str
88
+ bin_ids: list # bin index (dict key) per position
89
+ a: np.ndarray # interval lower bounds, sorted
90
+ b: np.ndarray # interval upper bounds
91
+ g: np.ndarray # position of the bin owning each interval
92
+ key_map: dict # cat / missing key -> position
93
+
94
+ def assign_values(self, x: np.ndarray) -> np.ndarray:
95
+ """Bin position of each continuous value, or -1 if it falls in no interval."""
96
+ if not self.a.size:
97
+ return np.full(x.shape[0], -1, dtype=np.int64)
98
+ if self.trend == "pos": # (a, b]
99
+ i = np.searchsorted(self.b, x, side="left")
100
+ valid = i < self.b.size
101
+ i = np.minimum(i, self.b.size - 1)
102
+ ok = valid & ((x > self.a[i]) | np.isneginf(self.a[i]))
103
+ else: # [a, b)
104
+ i = np.searchsorted(self.a, x, side="right") - 1
105
+ valid = i >= 0
106
+ i = np.maximum(i, 0)
107
+ ok = valid & ((x < self.b[i]) | np.isposinf(self.b[i]))
108
+ return np.where(ok, self.g[i], -1)
109
+
110
+ def assign(self, parsed: ParsedColumn, cont_codes: np.ndarray | None = None) -> np.ndarray:
111
+ """Bin position per row, or -1 for values that fall in no bin."""
112
+ out = np.full(parsed.n, -1, dtype=np.int64)
113
+ if parsed.x_cont.size:
114
+ if cont_codes is None:
115
+ cont_codes = self.assign_values(parsed.x_cont)
116
+ out[parsed.cont_mask] = cont_codes
117
+ for codes, keys in (
118
+ (parsed.cat_codes, parsed.cat_keys),
119
+ (parsed.miss_codes, parsed.miss_keys),
120
+ ):
121
+ if not keys:
122
+ continue
123
+ lut = np.array([self.key_map.get(k, -1) for k in keys] + [-1], dtype=np.int64)
124
+ m = codes >= 0
125
+ out[m] = lut[codes[m]]
126
+ return out
127
+
128
+
129
+ def compile_bins(bins: dict, feature: str = "") -> CompiledBins:
130
+ """Compile a bins dict; raises on overlapping intervals or duplicate keys."""
131
+ bin_ids = sorted(bins["bins"])
132
+ pos_of = {b: i for i, b in enumerate(bin_ids)}
133
+ intervals, key_map = [], {}
134
+ for b, atoms in bins["bins"].items():
135
+ for atom, kind in atoms.items():
136
+ if kind == "cont":
137
+ intervals.append((atom[0], atom[1], pos_of[b]))
138
+ else:
139
+ if atom in key_map:
140
+ raise ValueError(f"{feature}: key {atom!r} appears in more than one bin")
141
+ key_map[atom] = pos_of[b]
142
+ intervals.sort()
143
+ a = np.array([t[0] for t in intervals], dtype=np.float64)
144
+ bb = np.array([t[1] for t in intervals], dtype=np.float64)
145
+ g = np.array([t[2] for t in intervals], dtype=np.int64)
146
+ if a.size > 1 and np.any(a[1:] < bb[:-1]):
147
+ raise ValueError(f"{feature}: continuous intervals overlap")
148
+ return CompiledBins(bins.get("trend", "pos"), bin_ids, a, bb, g, key_map)
149
+
150
+
151
+ def _fmt(v: float, decimals: int = 4) -> str:
152
+ if np.isinf(v):
153
+ return "inf" if v > 0 else "-inf"
154
+ if float(v).is_integer():
155
+ return str(int(v))
156
+ return f"{v:.{decimals}f}"
157
+
158
+
159
+ def bin_label(atoms: dict, trend: str, decimals: int = 4) -> str:
160
+ """Display label of a bin, e.g. ``(-inf,0.3400]; special_category``.
161
+
162
+ Integer bounds print without decimals (``(18,25]``); other bounds print with exactly
163
+ ``decimals`` decimal places, trailing zeros kept. Only the label is rounded: the bins
164
+ themselves keep the full values.
165
+ """
166
+ parts = []
167
+ for atom, kind in atoms.items():
168
+ if kind == "cont":
169
+ a, b = atom
170
+ if trend == "pos":
171
+ right = ")" if np.isinf(b) else "]"
172
+ parts.append(f"({_fmt(a, decimals)},{_fmt(b, decimals)}{right}")
173
+ else:
174
+ left = "(" if np.isinf(a) else "["
175
+ parts.append(f"{left}{_fmt(a, decimals)},{_fmt(b, decimals)})")
176
+ else:
177
+ parts.append("NaN" if atom is None else str(atom))
178
+ return "; ".join(parts)
tide/_categorical.py ADDED
@@ -0,0 +1,200 @@
1
+ """Merging strategies for categorical bins: ChiMerge and the TIDE stability merge."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ from scipy.stats import chi2
7
+
8
+ from ._greedy import EPS
9
+
10
+ __all__ = ["chi2_pvalues", "chi_merge", "tide_merge"]
11
+
12
+
13
+ def chi2_pvalues(a0, a1, b0, b1) -> np.ndarray:
14
+ """Vectorized p-values of the 2x2 chi-squared test with Yates' correction.
15
+
16
+ Matches ``scipy.stats.chi2_contingency([[a0, a1], [b0, b1]]).pvalue``. As in v0.5,
17
+ tables with two or more zero cells are given p = 1, unless the two bins are
18
+ perfectly separated (``[[0, +], [+, 0]]`` or ``[[+, 0], [0, +]]``).
19
+ """
20
+ obs = np.stack(
21
+ [np.asarray(a0, float), np.asarray(a1, float), np.asarray(b0, float), np.asarray(b1, float)]
22
+ )
23
+ zeros = (obs == 0).sum(axis=0)
24
+ diag = ((obs[0] == 0) & (obs[1] > 0) & (obs[2] > 0) & (obs[3] == 0)) | (
25
+ (obs[0] > 0) & (obs[1] == 0) & (obs[2] == 0) & (obs[3] > 0)
26
+ )
27
+ force_one = (zeros >= 2) & ~diag
28
+
29
+ ra, rb = obs[0] + obs[1], obs[2] + obs[3]
30
+ c0, c1 = obs[0] + obs[2], obs[1] + obs[3]
31
+ tot = ra + rb
32
+ with np.errstate(divide="ignore", invalid="ignore"):
33
+ exp = np.stack([ra * c0, ra * c1, rb * c0, rb * c1]) / tot
34
+ diff = exp - obs
35
+ adj = obs + np.sign(diff) * np.minimum(0.5, np.abs(diff))
36
+ stat = ((adj - exp) ** 2 / exp).sum(axis=0)
37
+ p = chi2.sf(stat, 1)
38
+ p = np.where(force_one, 1.0, p)
39
+ return np.atleast_1d(p)
40
+
41
+
42
+ def chi_merge(n0: np.ndarray, n1: np.ndarray, alpha: float) -> list[list[int]]:
43
+ """ChiMerge (Kerber, 1992) over categories, as in v0.5.
44
+
45
+ Any two bins may merge. The pair with the highest p-value merges while that
46
+ p-value is above ``alpha``. The merged bin moves to the end of the bin list,
47
+ and ties resolve to the pair first seen (v0.5 order).
48
+
49
+ Returns the groups as lists of category indices, in v0.5 output order.
50
+ """
51
+ k = n0.shape[0]
52
+ if k < 2:
53
+ return [[i] for i in range(k)]
54
+ members: dict[int, list[int]] = {i: [i] for i in range(k)}
55
+ c0 = np.asarray(n0, float).copy()
56
+ c1 = np.asarray(n1, float).copy()
57
+ order = list(range(k)) # slot ids in bin-list order
58
+
59
+ pv = np.full((k, k), -np.inf)
60
+ rank = np.full((k, k), np.iinfo(np.int64).max, dtype=np.int64)
61
+ iu, ju = np.triu_indices(k, 1)
62
+ pv[iu, ju] = chi2_pvalues(c0[iu], c1[iu], c0[ju], c1[ju])
63
+ rank[iu, ju] = np.arange(iu.size)
64
+ next_rank = iu.size
65
+
66
+ while len(order) > 1:
67
+ best = pv.max()
68
+ if best <= alpha:
69
+ break
70
+ cand = pv == best
71
+ r = np.where(cand, rank, np.iinfo(np.int64).max)
72
+ i, j = np.unravel_index(np.argmin(r), r.shape)
73
+ i, j = int(i), int(j)
74
+ # merge slot j into slot i; the merged bin goes to the end of the list
75
+ members[i] = members[i] + members.pop(j)
76
+ c0[i] += c0[j]
77
+ c1[i] += c1[j]
78
+ order = [s for s in order if s not in (i, j)] + [i]
79
+ for s in (i, j):
80
+ pv[s, :] = -np.inf
81
+ pv[:, s] = -np.inf
82
+ others = np.array(order[:-1], dtype=np.int64)
83
+ if others.size:
84
+ p_new = chi2_pvalues(
85
+ c0[others], c1[others], np.full(others.size, c0[i]), np.full(others.size, c1[i])
86
+ )
87
+ lo = np.minimum(others, i)
88
+ hi = np.maximum(others, i)
89
+ pv[lo, hi] = p_new
90
+ rank[lo, hi] = next_rank + np.arange(others.size)
91
+ next_rank += others.size
92
+
93
+ return [sorted(members[s]) for s in order]
94
+
95
+
96
+ def tide_merge(
97
+ counts: np.ndarray,
98
+ events: np.ndarray,
99
+ n_cont: int,
100
+ trend: str,
101
+ min_sample_rate: float,
102
+ min_class_obs: int,
103
+ min_er_delta: float,
104
+ ) -> list[list[int]]:
105
+ """Merge atomic bins (continuous bins, categories, missing) until all constraints hold.
106
+
107
+ Atoms ``0..n_cont-1`` are the continuous bins, in axis order. Violations are fixed
108
+ in this priority order:
109
+
110
+ 0. monotonic event rate of the bins with continuous intervals, in every period;
111
+ 1. no crossings of event-rate curves between any two bins (v0.5);
112
+ 2. minimum event-rate gap ``min_er_delta`` between any two bins (v0.5);
113
+ 3. bin size ``>= min_sample_rate`` in every period (v0.5);
114
+ 4. ``>= min_class_obs`` events and non-events in every period (v0.5).
115
+
116
+ For 1-4, the offending bin (the smallest by rows, on ties) merges with the bin
117
+ closest by squared event-rate distance. Two bins with continuous intervals may merge
118
+ only if they are adjacent, so every bin keeps one contiguous interval.
119
+
120
+ Returns the groups as lists of atom indices.
121
+ """
122
+ pos = trend == "pos"
123
+ c = counts.astype(np.float64).copy()
124
+ e = events.astype(np.float64).copy()
125
+ groups: list[list[int]] = [[a] for a in range(c.shape[0])]
126
+ lo = np.array([a if a < n_cont else -1 for a in range(c.shape[0])], dtype=np.int64)
127
+ hi = lo.copy()
128
+
129
+ def merge(t: int, g: int) -> None:
130
+ nonlocal c, e, lo, hi
131
+ groups[t] = groups[t] + groups[g]
132
+ c[t] += c[g]
133
+ e[t] += e[g]
134
+ if lo[g] >= 0:
135
+ lo[t] = lo[g] if lo[t] < 0 else min(lo[t], lo[g])
136
+ hi[t] = max(hi[t], hi[g])
137
+ del groups[g]
138
+ c = np.delete(c, g, axis=0)
139
+ e = np.delete(e, g, axis=0)
140
+ lo = np.delete(lo, g)
141
+ hi = np.delete(hi, g)
142
+
143
+ while len(groups) > 1:
144
+ er = e / (c + EPS)
145
+ tot = c.sum(axis=1)
146
+ has_cont = lo >= 0
147
+
148
+ # 0. monotonicity of continuous bins
149
+ ci = np.flatnonzero(has_cont)
150
+ if ci.size > 1:
151
+ ci = ci[np.argsort(lo[ci])]
152
+ d = er[ci[1:]] - er[ci[:-1]]
153
+ viol = (d < 0).any(axis=1) if pos else (d > 0).any(axis=1)
154
+ if viol.any():
155
+ cand = np.flatnonzero(viol)
156
+ pair_tot = tot[ci[cand]] + tot[ci[cand + 1]]
157
+ q = cand[np.argmin(pair_tot)]
158
+ t, g = int(ci[q]), int(ci[q + 1])
159
+ merge(t, g)
160
+ continue
161
+
162
+ sgn = (er[:, None, :] > er[None, :, :]).astype(np.int8) - (
163
+ er[:, None, :] < er[None, :, :]
164
+ ).astype(np.int8)
165
+ crossings = (sgn[:, :, :-1] != sgn[:, :, 1:]).sum(axis=2).sum(axis=1)
166
+ dist = np.abs(er[:, None, :] - er[None, :, :]).min(axis=2)
167
+ np.fill_diagonal(dist, np.inf)
168
+ delta = dist.min(axis=1)
169
+ sample_rate = (c / c.sum(axis=0)).min(axis=1)
170
+ ev_min = e.min(axis=1)
171
+ nev_min = (c - e).min(axis=1)
172
+
173
+ if crossings.max() > 0:
174
+ bad = crossings == crossings.max()
175
+ elif delta.min() < min_er_delta:
176
+ bad = delta == delta.min()
177
+ elif sample_rate.min() < min_sample_rate:
178
+ bad = sample_rate == sample_rate.min()
179
+ elif ev_min.min() < min_class_obs:
180
+ bad = ev_min == ev_min.min()
181
+ elif nev_min.min() < min_class_obs:
182
+ bad = nev_min == nev_min.min()
183
+ else:
184
+ break
185
+ cand = np.flatnonzero(bad)
186
+ t = int(cand[np.argmin(tot[cand])])
187
+
188
+ allowed = np.ones(len(groups), dtype=bool)
189
+ allowed[t] = False
190
+ if has_cont[t]:
191
+ adjacent = (hi[t] + 1 == lo) | (hi + 1 == lo[t])
192
+ allowed &= ~has_cont | adjacent
193
+ if not allowed.any(): # pragma: no cover - cannot happen with >= 2 groups
194
+ break
195
+ sq = ((er[t] - er) ** 2).sum(axis=1)
196
+ sq[~allowed] = np.inf
197
+ g = int(np.argmin(sq))
198
+ merge(t, g)
199
+
200
+ return groups