edgepoint 1.0.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.
- edgepoint/__init__.py +16 -0
- edgepoint/core.py +573 -0
- edgepoint-1.0.0.dist-info/METADATA +522 -0
- edgepoint-1.0.0.dist-info/RECORD +7 -0
- edgepoint-1.0.0.dist-info/WHEEL +5 -0
- edgepoint-1.0.0.dist-info/licenses/LICENSE +21 -0
- edgepoint-1.0.0.dist-info/top_level.txt +1 -0
edgepoint/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""
|
|
2
|
+
edgepoint, find the point in a numeric column above which a binary
|
|
3
|
+
outcome rate becomes noticeably, reliably better.
|
|
4
|
+
|
|
5
|
+
from edgepoint import find
|
|
6
|
+
|
|
7
|
+
profile = find(df, outcome_col="hit", compute_fallback=True)
|
|
8
|
+
# {"score": 66.75, "age": None, ...}
|
|
9
|
+
|
|
10
|
+
See edgepoint.core.find for the full parameter reference.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .core import find, VERSION
|
|
14
|
+
|
|
15
|
+
__version__ = VERSION
|
|
16
|
+
__all__ = ["find"]
|
edgepoint/core.py
ADDED
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
"""
|
|
2
|
+
edgepoint/core.py (VERSION 1.0.0)
|
|
3
|
+
|
|
4
|
+
Answers one question, for any numeric column and any binary outcome:
|
|
5
|
+
|
|
6
|
+
"Walk this column from its min to its max in even steps, and check
|
|
7
|
+
every possible split point along the way: where's the first
|
|
8
|
+
place the split is actually big enough to matter?"
|
|
9
|
+
|
|
10
|
+
That point is the column's SWEET SPOT: the value above which the
|
|
11
|
+
outcome rate becomes noticeably, reliably better than below it.
|
|
12
|
+
|
|
13
|
+
METHOD (two-tier: primary quality bar, then OPTIONAL coverage
|
|
14
|
+
fallback, both with a tolerance-based tiebreak that prefers
|
|
15
|
+
coverage; a single coverage floor gates every edge)
|
|
16
|
+
---------------------------------------------------------------------
|
|
17
|
+
1. Take the column's own [min, max] range and cut it into `range_bins`
|
|
18
|
+
equal-WIDTH steps (np.linspace) to get edge points to walk. No
|
|
19
|
+
per-step binning, at each edge the WHOLE column is split directly
|
|
20
|
+
into >= edge and < edge and each side's rate is computed on the
|
|
21
|
+
spot.
|
|
22
|
+
|
|
23
|
+
WHY equal-width, not quantile: quantile (qcut) edges are
|
|
24
|
+
population-based, they guarantee similar ROW COUNT per step, not
|
|
25
|
+
similar VALUE RANGE. On a column with a lot of duplicate/repeated
|
|
26
|
+
values (e.g. a rate or score with hundreds of rows sitting at or
|
|
27
|
+
near 0), quantile edges collapse toward the crowded region, and a
|
|
28
|
+
real effect sitting AT 0 gets buried inside one wide edge alongside
|
|
29
|
+
completely different values. Equal-width edges are spaced evenly
|
|
30
|
+
across the RANGE itself, so a cluster at 0 still gets a nearby edge
|
|
31
|
+
to split on.
|
|
32
|
+
|
|
33
|
+
2. Walk every internal edge, low to high (there are range_bins - 1 of
|
|
34
|
+
them). At each edge:
|
|
35
|
+
- gap = rate(>= edge) - rate(< edge)
|
|
36
|
+
- coverage = above_n / total_n
|
|
37
|
+
An edge is skipped outright if coverage < min_coverage_pct, this
|
|
38
|
+
is the ONE guard against thin, unreliable "above" groups, checked
|
|
39
|
+
directly against actual row count rather than range-position.
|
|
40
|
+
|
|
41
|
+
WHY coverage instead of range-position: on a skewed column,
|
|
42
|
+
range-position and row coverage decouple. An edge at 67% of the way
|
|
43
|
+
across the range can still cover 4% of rows, while an edge at 20%
|
|
44
|
+
of the way across can cover 91%, range-position is only ever a
|
|
45
|
+
proxy for "is this a thin sliver," and a proxy that can be badly
|
|
46
|
+
wrong. Checking coverage directly is the more honest guard for the
|
|
47
|
+
thing actually being protected against.
|
|
48
|
+
|
|
49
|
+
Only POSITIVE gaps (above side better) are ever candidates. All of
|
|
50
|
+
this is printed per edge when show_progress=True.
|
|
51
|
+
|
|
52
|
+
3. PRIMARY condition: an edge (already coverage-qualified per step 2)
|
|
53
|
+
is a primary candidate if BOTH:
|
|
54
|
+
- gap >= gap_threshold (default 10 = 10 percentage points)
|
|
55
|
+
- above_rate >= target_pct (default 60 = 60%)
|
|
56
|
+
Among all primary candidates, the winner is chosen by
|
|
57
|
+
`primary_tiebreak`:
|
|
58
|
+
- "gap" (default) -> largest gap wins
|
|
59
|
+
- "rate" -> highest above_rate wins
|
|
60
|
+
TOLERANCE: rather than picking the single literal max, every
|
|
61
|
+
candidate within `fallback_tolerance_pct` (default 3, i.e. 3
|
|
62
|
+
percentage points) of the best value on the tiebreak metric is
|
|
63
|
+
treated as "tied." Among the tied group, the edge with the highest
|
|
64
|
+
coverage (broadest above_n share, i.e. the lowest/earliest edge)
|
|
65
|
+
wins. This avoids picking a split that's technically 0.4pp better
|
|
66
|
+
but throws away 5+ points of coverage for a difference that's
|
|
67
|
+
really just noise.
|
|
68
|
+
|
|
69
|
+
4. FALLBACK: controlled by the MANDATORY `compute_fallback` parameter.
|
|
70
|
+
This tier only ever runs if NO edge satisfies the primary condition
|
|
71
|
+
AND `compute_fallback=True`.
|
|
72
|
+
- compute_fallback=True: an edge is a fallback candidate if
|
|
73
|
+
gap > 0 (above side still better, just not by enough / not
|
|
74
|
+
high enough rate to clear the primary bar), coverage is
|
|
75
|
+
already guaranteed by step 2's floor, so nothing further to
|
|
76
|
+
check there. Among fallback candidates, the highest above_rate
|
|
77
|
+
sets the bar, then the SAME tolerance rule as PRIMARY applies:
|
|
78
|
+
any candidate within `fallback_tolerance_pct` of that best rate
|
|
79
|
+
is "tied," and the highest-coverage edge among the tied group
|
|
80
|
+
wins, since "50.5% beats 50.1%" isn't a real reason to prefer
|
|
81
|
+
a thinner split over a broader one differing by noise.
|
|
82
|
+
- compute_fallback=False: the fallback tier is skipped entirely.
|
|
83
|
+
If no edge clears PRIMARY, the column is reported as None,
|
|
84
|
+
full stop, fallback candidates are never even evaluated.
|
|
85
|
+
`compute_fallback` has no default value and MUST be passed
|
|
86
|
+
explicitly (True or False) on every call, omitting it raises a
|
|
87
|
+
ValueError. This is deliberate: whether a column falls back to a
|
|
88
|
+
"best available, if weaker" split is a modeling decision that
|
|
89
|
+
should never be silently defaulted.
|
|
90
|
+
|
|
91
|
+
5. If neither the primary condition nor an enabled fallback finds
|
|
92
|
+
anything, the column is reported as having no usable split (None).
|
|
93
|
+
This includes the case where every edge has a negative or zero gap,
|
|
94
|
+
i.e. "above" is never better than "below" anywhere in the column.
|
|
95
|
+
That is a legitimate, honest result, not a gap to be patched: the
|
|
96
|
+
function's job is to report where a real sweet spot exists, not to
|
|
97
|
+
always produce a number by picking the least-bad option when no
|
|
98
|
+
such split exists.
|
|
99
|
+
|
|
100
|
+
No credible intervals, no significance tests, no baseline comparison,
|
|
101
|
+
on purpose. This is a plain, easy-to-reason-about check: "is there a
|
|
102
|
+
point in this column's range where splitting it actually separates two
|
|
103
|
+
meaningfully different groups." gap_threshold / target_pct /
|
|
104
|
+
min_coverage_pct / fallback_tolerance_pct are your quality controls, so
|
|
105
|
+
set them deliberately.
|
|
106
|
+
|
|
107
|
+
Note on directionality: this function only ever searches for a rising
|
|
108
|
+
sweet spot: a point above which the outcome rate is better. It does
|
|
109
|
+
not independently verify that the "below" side is bad on its own bar;
|
|
110
|
+
it only knows that "below" is worse than "above." Usually that amounts
|
|
111
|
+
to the same practical thing, but it's a comparative statement, not two
|
|
112
|
+
independently-scored zones.
|
|
113
|
+
|
|
114
|
+
UNITS: every percentage-style parameter is a plain 0-100 number, not a
|
|
115
|
+
0-1 fraction, gap_threshold=10 means 10 percentage points,
|
|
116
|
+
target_pct=60 means 60%, min_coverage_pct=30 means 30%,
|
|
117
|
+
fallback_tolerance_pct=3 means 3 percentage points. Internally these
|
|
118
|
+
are converted to 0-1 fractions for comparison against computed
|
|
119
|
+
rates/gaps.
|
|
120
|
+
|
|
121
|
+
USAGE
|
|
122
|
+
-----
|
|
123
|
+
from edgepoint import find
|
|
124
|
+
|
|
125
|
+
profile = find(
|
|
126
|
+
df,
|
|
127
|
+
outcome_col="converted", # any binary (0/1) outcome column
|
|
128
|
+
compute_fallback=True, # MANDATORY, must be True or False
|
|
129
|
+
columns=None, # None = auto-scan every numeric col
|
|
130
|
+
gap_threshold=10, # required gap (pp) for the primary condition
|
|
131
|
+
target_pct=60, # required above_rate (%) for the primary condition
|
|
132
|
+
min_coverage_pct=30, # required above_n share (%) for ANY edge to qualify
|
|
133
|
+
fallback_tolerance_pct=3, # "close enough" band (pp) for tiebreaks; prefers coverage
|
|
134
|
+
primary_tiebreak="gap", # "gap" or "rate"
|
|
135
|
+
range_bins=15, # number of equal-width steps across [min, max]
|
|
136
|
+
show_progress=True, # print every edge's split as it's checked
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# profile is a flat dict, one entry per column:
|
|
140
|
+
# {
|
|
141
|
+
# "colA": 44.44, # sweet-spot value, or None if no usable split
|
|
142
|
+
# "colB": None,
|
|
143
|
+
# ...
|
|
144
|
+
# }
|
|
145
|
+
|
|
146
|
+
EXAMPLES OF WHERE THIS APPLIES
|
|
147
|
+
-------------------------------
|
|
148
|
+
Any continuous metric + binary outcome pair, for example:
|
|
149
|
+
- marketing: at what engagement score does churn risk drop off
|
|
150
|
+
- credit: at what score does default rate become acceptable
|
|
151
|
+
- healthcare: at what biomarker level does an outcome rate jump
|
|
152
|
+
- product: at what usage count does upgrade-to-paid rate spike
|
|
153
|
+
- manufacturing: at what sensor reading does defect rate climb
|
|
154
|
+
- trading/betting: at what signal-strength value does a pick's
|
|
155
|
+
hit rate become reliable
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
import numpy as np
|
|
159
|
+
import pandas as pd
|
|
160
|
+
|
|
161
|
+
VERSION = "1.0.0"
|
|
162
|
+
|
|
163
|
+
# ── config defaults ──────────────────────────────────────────────────────
|
|
164
|
+
# Number of equal-width steps to cut the column's [min, max] range into.
|
|
165
|
+
DEFAULT_RANGE_BINS = 15
|
|
166
|
+
|
|
167
|
+
# Minimum gap (in percentage points, e.g. 10 = 10 percentage points)
|
|
168
|
+
# required for the PRIMARY condition.
|
|
169
|
+
DEFAULT_GAP_THRESHOLD = 10
|
|
170
|
+
|
|
171
|
+
# Minimum above_rate (in %, e.g. 60 = 60%) required for the PRIMARY
|
|
172
|
+
# condition.
|
|
173
|
+
DEFAULT_TARGET_PCT = 60
|
|
174
|
+
|
|
175
|
+
# Minimum share of total rows (in %, e.g. 30 = 30%) the "above" side
|
|
176
|
+
# must cover for an edge to qualify as a candidate AT ALL, checked up
|
|
177
|
+
# front for every edge, primary and fallback alike. This is the one
|
|
178
|
+
# guard against thin, unreliable "above" groups.
|
|
179
|
+
DEFAULT_MIN_COVERAGE_PCT = 30
|
|
180
|
+
|
|
181
|
+
# "Close enough" tolerance band, in percentage points, used to decide
|
|
182
|
+
# ties on the tiebreak metric (gap or above_rate) for BOTH the primary
|
|
183
|
+
# and fallback tiers. Any candidate within this many points of the
|
|
184
|
+
# best value is treated as tied; among tied candidates, the one with
|
|
185
|
+
# the highest coverage wins.
|
|
186
|
+
DEFAULT_FALLBACK_TOLERANCE_PCT = 3
|
|
187
|
+
|
|
188
|
+
# How to pick among edges that satisfy the PRIMARY condition.
|
|
189
|
+
# "gap" = largest gap wins (within tolerance). "rate" = highest
|
|
190
|
+
# above_rate wins (within tolerance).
|
|
191
|
+
DEFAULT_PRIMARY_TIEBREAK = "gap"
|
|
192
|
+
|
|
193
|
+
# Minimum rows required before even attempting to scan a column at all.
|
|
194
|
+
DEFAULT_MIN_ROWS = 15
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _build_range_edges(col_values, n_bins):
|
|
198
|
+
"""
|
|
199
|
+
Equal-WIDTH edge points across the column's own [min, max] range,
|
|
200
|
+
np.linspace(min, max, n_bins + 1), NOT quantile/qcut. Returns only
|
|
201
|
+
the INTERNAL edges (n_bins - 1 of them), the outer min/max aren't
|
|
202
|
+
useful split points since one side would always be empty.
|
|
203
|
+
"""
|
|
204
|
+
lo, hi = float(np.min(col_values)), float(np.max(col_values))
|
|
205
|
+
if lo == hi:
|
|
206
|
+
return None # no range to split
|
|
207
|
+
all_edges = np.linspace(lo, hi, n_bins + 1)
|
|
208
|
+
return all_edges[1:-1] # drop the min and max themselves
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _pick_with_tolerance(candidates, key, tolerance_frac):
|
|
212
|
+
"""
|
|
213
|
+
Picks the "winner" among `candidates` on metric `key`, but instead
|
|
214
|
+
of taking the literal single max, first finds the best value on
|
|
215
|
+
`key`, then treats every candidate within `tolerance_frac` of that
|
|
216
|
+
best value as tied. Among the tied group, the highest-coverage
|
|
217
|
+
candidate wins (ties broken by lowest breakout_value, i.e. the
|
|
218
|
+
earliest/broadest edge). This avoids picking a split that's only
|
|
219
|
+
a fraction of a point better on `key` while covering meaningfully
|
|
220
|
+
less of the data.
|
|
221
|
+
|
|
222
|
+
Returns (winning candidate dict, tied list).
|
|
223
|
+
"""
|
|
224
|
+
best_val = max(c[key] for c in candidates)
|
|
225
|
+
tied = [c for c in candidates if (best_val - c[key]) <= tolerance_frac]
|
|
226
|
+
# highest coverage wins; break remaining ties by lowest breakout_value
|
|
227
|
+
winner = max(tied, key=lambda c: (c["coverage"], -c["breakout_value"]))
|
|
228
|
+
return winner, tied
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _scan_edges(
|
|
232
|
+
col_values, y, edges, show_progress,
|
|
233
|
+
compute_fallback,
|
|
234
|
+
gap_threshold=DEFAULT_GAP_THRESHOLD,
|
|
235
|
+
target_pct=DEFAULT_TARGET_PCT,
|
|
236
|
+
min_coverage_pct=DEFAULT_MIN_COVERAGE_PCT,
|
|
237
|
+
fallback_tolerance_pct=DEFAULT_FALLBACK_TOLERANCE_PCT,
|
|
238
|
+
primary_tiebreak=DEFAULT_PRIMARY_TIEBREAK,
|
|
239
|
+
):
|
|
240
|
+
"""
|
|
241
|
+
Walks every internal edge (low to high), splitting the WHOLE
|
|
242
|
+
column directly into >= edge and < edge at each one and computing
|
|
243
|
+
each side's rate on the spot. Prints each edge's split when
|
|
244
|
+
show_progress=True.
|
|
245
|
+
|
|
246
|
+
Every edge is checked against the min_coverage_pct floor first,
|
|
247
|
+
if the "above" side covers less than min_coverage_pct of total
|
|
248
|
+
rows, the edge is skipped outright and never becomes a candidate,
|
|
249
|
+
for primary OR fallback. This is the sole guard against thin,
|
|
250
|
+
unreliable "above" groups.
|
|
251
|
+
|
|
252
|
+
All threshold params (gap_threshold, target_pct, min_coverage_pct,
|
|
253
|
+
fallback_tolerance_pct) are plain 0-100 numbers; they're converted
|
|
254
|
+
to 0-1 fractions here for comparison against computed rates/gaps.
|
|
255
|
+
|
|
256
|
+
Every coverage-qualified edge with a positive gap (above side
|
|
257
|
+
better) is kept as a row of candidate stats. After the walk:
|
|
258
|
+
- PRIMARY: among candidates with gap >= gap_threshold AND
|
|
259
|
+
above_rate >= target_pct, pick by primary_tiebreak
|
|
260
|
+
("gap" -> largest gap, "rate" -> highest above_rate), using
|
|
261
|
+
the tolerance rule (prefer coverage among near-ties).
|
|
262
|
+
- FALLBACK (only if no primary candidate, AND ONLY IF
|
|
263
|
+
compute_fallback=True): among all remaining candidates
|
|
264
|
+
(already coverage-qualified), pick the highest above_rate
|
|
265
|
+
using the same tolerance rule.
|
|
266
|
+
- Otherwise: None. This includes the case where no primary
|
|
267
|
+
candidate exists and compute_fallback=False, fallback
|
|
268
|
+
candidates are never evaluated in that case.
|
|
269
|
+
|
|
270
|
+
Returns the breakout_value (edge), or None.
|
|
271
|
+
"""
|
|
272
|
+
gap_threshold_frac = gap_threshold / 100.0
|
|
273
|
+
target_pct_frac = target_pct / 100.0
|
|
274
|
+
min_coverage_pct_frac = min_coverage_pct / 100.0
|
|
275
|
+
tolerance_frac = fallback_tolerance_pct / 100.0
|
|
276
|
+
|
|
277
|
+
total_n = len(col_values)
|
|
278
|
+
candidates = [] # each: {"breakout_value", "gap", "above_rate", "above_n", "coverage"}
|
|
279
|
+
|
|
280
|
+
for edge in edges:
|
|
281
|
+
above_mask = col_values >= edge
|
|
282
|
+
below_mask = ~above_mask
|
|
283
|
+
|
|
284
|
+
above_n = int(above_mask.sum())
|
|
285
|
+
below_n = int(below_mask.sum())
|
|
286
|
+
|
|
287
|
+
if above_n == 0 or below_n == 0:
|
|
288
|
+
if show_progress:
|
|
289
|
+
print(f" edge={edge:.4g} "
|
|
290
|
+
f"skipped, nothing on one side (above_n={above_n}, below_n={below_n})")
|
|
291
|
+
continue
|
|
292
|
+
|
|
293
|
+
coverage = above_n / total_n
|
|
294
|
+
|
|
295
|
+
if coverage < min_coverage_pct_frac:
|
|
296
|
+
if show_progress:
|
|
297
|
+
print(f" edge={edge:.4g} "
|
|
298
|
+
f"(coverage {coverage:.0%} < min_coverage_pct={min_coverage_pct:.0f}%) "
|
|
299
|
+
f"→ skipped")
|
|
300
|
+
continue
|
|
301
|
+
|
|
302
|
+
above_rate = float(y[above_mask].mean())
|
|
303
|
+
below_rate = float(y[below_mask].mean())
|
|
304
|
+
gap = above_rate - below_rate # positive = "above" side is better
|
|
305
|
+
|
|
306
|
+
if show_progress:
|
|
307
|
+
print(f" edge={edge:.4g} "
|
|
308
|
+
f">= {edge:.4g}: n={above_n:<5} rate={above_rate:.1%} "
|
|
309
|
+
f"< {edge:.4g}: n={below_n:<5} rate={below_rate:.1%} "
|
|
310
|
+
f"gap={gap:+.1%} coverage={coverage:.0%}")
|
|
311
|
+
|
|
312
|
+
if gap <= 0:
|
|
313
|
+
continue # not the "above is better" shape we're looking for
|
|
314
|
+
|
|
315
|
+
candidates.append({
|
|
316
|
+
"breakout_value": float(edge),
|
|
317
|
+
"gap": gap,
|
|
318
|
+
"above_rate": above_rate,
|
|
319
|
+
"above_n": above_n,
|
|
320
|
+
"coverage": coverage,
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
if not candidates:
|
|
324
|
+
if show_progress:
|
|
325
|
+
print(f" → no edge had a usable positive gap "
|
|
326
|
+
f"at or above min_coverage_pct={min_coverage_pct:.0f}%")
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
# ── PRIMARY: gap AND target_pct both required ──────────────────
|
|
330
|
+
primary = [c for c in candidates
|
|
331
|
+
if c["gap"] >= gap_threshold_frac and c["above_rate"] >= target_pct_frac]
|
|
332
|
+
|
|
333
|
+
if primary:
|
|
334
|
+
key = "gap" if primary_tiebreak == "gap" else "above_rate"
|
|
335
|
+
best, tied = _pick_with_tolerance(primary, key, tolerance_frac)
|
|
336
|
+
if show_progress:
|
|
337
|
+
tie_note = (f" (tied with {len(tied)-1} other edge(s) within "
|
|
338
|
+
f"{fallback_tolerance_pct:.0f}pp on {key}; "
|
|
339
|
+
f"highest coverage wins)") if len(tied) > 1 else ""
|
|
340
|
+
print(f" → PRIMARY: split at {best['breakout_value']:.4g}, "
|
|
341
|
+
f"gap={best['gap']:.1%}, above_rate={best['above_rate']:.1%}, "
|
|
342
|
+
f"coverage={best['coverage']:.0%} "
|
|
343
|
+
f"(>= gap_threshold={gap_threshold:.0f}% AND "
|
|
344
|
+
f"target_pct={target_pct:.0f}%; tiebreak={primary_tiebreak}){tie_note}")
|
|
345
|
+
return round(best["breakout_value"], 2)
|
|
346
|
+
|
|
347
|
+
if show_progress:
|
|
348
|
+
print(f" → no edge cleared PRIMARY (gap_threshold={gap_threshold:.0f}% "
|
|
349
|
+
f"AND target_pct={target_pct:.0f}%)")
|
|
350
|
+
|
|
351
|
+
# ── FALLBACK: only runs if compute_fallback=True ────────────────
|
|
352
|
+
if not compute_fallback:
|
|
353
|
+
if show_progress:
|
|
354
|
+
print(f" → compute_fallback=False, fallback skipped, column reported as None")
|
|
355
|
+
return None
|
|
356
|
+
|
|
357
|
+
if show_progress:
|
|
358
|
+
print(f" → trying fallback")
|
|
359
|
+
|
|
360
|
+
# candidates is non-empty here (checked above), and every entry
|
|
361
|
+
# already cleared the coverage floor up front, so fallback just
|
|
362
|
+
# picks the highest rate (w/ tolerance) among them
|
|
363
|
+
best, tied = _pick_with_tolerance(candidates, "above_rate", tolerance_frac)
|
|
364
|
+
if show_progress:
|
|
365
|
+
tie_note = (f" (tied with {len(tied)-1} other edge(s) within "
|
|
366
|
+
f"{fallback_tolerance_pct:.0f}pp on above_rate; "
|
|
367
|
+
f"highest coverage wins)") if len(tied) > 1 else ""
|
|
368
|
+
print(f" → FALLBACK: split at {best['breakout_value']:.4g}, "
|
|
369
|
+
f"above_rate={best['above_rate']:.1%}, "
|
|
370
|
+
f"coverage={best['coverage']:.0%} "
|
|
371
|
+
f"(>= min_coverage_pct={min_coverage_pct:.0f}%){tie_note}")
|
|
372
|
+
return round(best["breakout_value"], 2)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def find(
|
|
376
|
+
df,
|
|
377
|
+
outcome_col,
|
|
378
|
+
compute_fallback=None,
|
|
379
|
+
columns=None,
|
|
380
|
+
range_bins=DEFAULT_RANGE_BINS,
|
|
381
|
+
gap_threshold=DEFAULT_GAP_THRESHOLD,
|
|
382
|
+
target_pct=DEFAULT_TARGET_PCT,
|
|
383
|
+
min_coverage_pct=DEFAULT_MIN_COVERAGE_PCT,
|
|
384
|
+
fallback_tolerance_pct=DEFAULT_FALLBACK_TOLERANCE_PCT,
|
|
385
|
+
primary_tiebreak=DEFAULT_PRIMARY_TIEBREAK,
|
|
386
|
+
min_rows=DEFAULT_MIN_ROWS,
|
|
387
|
+
show_progress=False,
|
|
388
|
+
):
|
|
389
|
+
"""
|
|
390
|
+
For each candidate column: cuts its [min, max] range into
|
|
391
|
+
`range_bins` equal-WIDTH steps to get edge points, then walks every
|
|
392
|
+
edge from low to high, splitting the whole column directly at each
|
|
393
|
+
edge. Any edge whose "above" side covers less than min_coverage_pct
|
|
394
|
+
of total rows is skipped outright before it can become a candidate,
|
|
395
|
+
this is the sole guard against thin, unreliable splits, checked
|
|
396
|
+
for every edge regardless of tier.
|
|
397
|
+
|
|
398
|
+
Two-tier pick, both tiers using a tolerance-based tiebreak that
|
|
399
|
+
prefers coverage among near-ties:
|
|
400
|
+
PRIMARY , edge must clear BOTH gap_threshold AND target_pct.
|
|
401
|
+
Ties (within fallback_tolerance_pct on the
|
|
402
|
+
primary_tiebreak metric) broken by highest coverage.
|
|
403
|
+
FALLBACK, only evaluated if no edge clears PRIMARY, AND ONLY IF
|
|
404
|
+
compute_fallback=True: among all remaining (already
|
|
405
|
+
coverage-qualified) edges, pick the highest
|
|
406
|
+
above_rate; ties (within fallback_tolerance_pct)
|
|
407
|
+
broken by highest coverage.
|
|
408
|
+
Otherwise -> None.
|
|
409
|
+
|
|
410
|
+
Parameters
|
|
411
|
+
----------
|
|
412
|
+
df : pandas.DataFrame
|
|
413
|
+
Must contain `outcome_col` plus every column in `columns` (or,
|
|
414
|
+
if columns=None, every other numeric column is tried).
|
|
415
|
+
outcome_col : str
|
|
416
|
+
Name of the binary outcome column (0/1 or True/False).
|
|
417
|
+
compute_fallback : bool
|
|
418
|
+
MANDATORY, defaults to None only so its absence can be
|
|
419
|
+
detected; None is not a valid value. Must be passed explicitly
|
|
420
|
+
as True or False on every call. Omitting it (leaving it as
|
|
421
|
+
None) raises a ValueError, and passing anything other than
|
|
422
|
+
True/False/None also raises a ValueError.
|
|
423
|
+
Controls whether the FALLBACK tier is ever evaluated for a
|
|
424
|
+
column that fails to produce a PRIMARY candidate:
|
|
425
|
+
- True -> if no edge clears PRIMARY, fall back to the
|
|
426
|
+
highest-above_rate coverage-qualified edge
|
|
427
|
+
(tolerance tiebreak applies), same as before.
|
|
428
|
+
- False -> if no edge clears PRIMARY, the column is
|
|
429
|
+
reported as None. Fallback candidates are never
|
|
430
|
+
evaluated at all.
|
|
431
|
+
This is deliberately not defaulted: whether a column should
|
|
432
|
+
ever settle for a "best available, but weaker" split is a
|
|
433
|
+
modeling decision the caller must make consciously each time.
|
|
434
|
+
columns : list[str] or None
|
|
435
|
+
Which columns to scan. Defaults to every numeric column other
|
|
436
|
+
than outcome_col.
|
|
437
|
+
range_bins : int
|
|
438
|
+
Number of equal-width steps to cut the column's [min, max]
|
|
439
|
+
range into (produces range_bins - 1 internal edges to walk).
|
|
440
|
+
Default 15.
|
|
441
|
+
gap_threshold : float
|
|
442
|
+
PRIMARY condition: minimum gap (above_rate - below_rate)
|
|
443
|
+
required, in percentage points, e.g. 10 = 10 percentage points.
|
|
444
|
+
Default 10.
|
|
445
|
+
target_pct : float
|
|
446
|
+
PRIMARY condition: minimum above_rate required, in %,
|
|
447
|
+
e.g. 60 = 60%. Default 60.
|
|
448
|
+
min_coverage_pct : float
|
|
449
|
+
Minimum share of total rows the "above" side must cover, in %,
|
|
450
|
+
e.g. 30 = 30%, for an edge to qualify as a candidate at all,
|
|
451
|
+
checked for every edge, primary and fallback alike, before
|
|
452
|
+
gap/rate are even evaluated. Default 30.
|
|
453
|
+
fallback_tolerance_pct : float
|
|
454
|
+
Tolerance band, in percentage points, used for tiebreaking in
|
|
455
|
+
BOTH tiers. Any candidate within this many points of the best
|
|
456
|
+
value on the relevant tiebreak metric (gap or above_rate) is
|
|
457
|
+
treated as tied; among tied candidates, the one with the
|
|
458
|
+
highest coverage wins. Prevents picking a split that's only
|
|
459
|
+
marginally "better" on the metric while sacrificing meaningful
|
|
460
|
+
coverage. Default 3 (3 percentage points).
|
|
461
|
+
primary_tiebreak : str
|
|
462
|
+
Which metric to use for the PRIMARY tier's tiebreak.
|
|
463
|
+
"gap" (default) -> largest gap (within tolerance) wins, then
|
|
464
|
+
highest coverage. "rate" -> highest above_rate (within
|
|
465
|
+
tolerance) wins, then highest coverage.
|
|
466
|
+
min_rows : int
|
|
467
|
+
Minimum non-null rows required for a column to be scanned at
|
|
468
|
+
all. Default 15.
|
|
469
|
+
show_progress : bool
|
|
470
|
+
When True, prints every edge's split (value, each side's
|
|
471
|
+
n/rate/gap/coverage) as it's checked, plus which tier
|
|
472
|
+
(primary/fallback/none) produced the final pick, including a
|
|
473
|
+
note when the tolerance tiebreak was invoked, and whether
|
|
474
|
+
fallback was skipped because compute_fallback=False. This is
|
|
475
|
+
the only place that detail is visible; the returned dict is
|
|
476
|
+
deliberately flat.
|
|
477
|
+
|
|
478
|
+
Returns
|
|
479
|
+
-------
|
|
480
|
+
dict[str, float or None]
|
|
481
|
+
{column_name: breakout_point}. breakout_point is the value
|
|
482
|
+
above which the outcome rate is meaningfully higher (per the
|
|
483
|
+
primary condition, or the fallback if primary found nothing
|
|
484
|
+
and compute_fallback=True), or None if neither tier found a
|
|
485
|
+
usable split (including columns that couldn't be scanned at
|
|
486
|
+
all, too few rows, or a constant/no-range column, and
|
|
487
|
+
columns where PRIMARY failed and compute_fallback=False).
|
|
488
|
+
|
|
489
|
+
Raises
|
|
490
|
+
------
|
|
491
|
+
ValueError
|
|
492
|
+
If compute_fallback is not passed, or is passed as something
|
|
493
|
+
other than True/False.
|
|
494
|
+
"""
|
|
495
|
+
if compute_fallback is None:
|
|
496
|
+
raise ValueError(
|
|
497
|
+
"compute_fallback is mandatory and was not passed (got None), "
|
|
498
|
+
"you must explicitly pass compute_fallback=True or "
|
|
499
|
+
"compute_fallback=False."
|
|
500
|
+
)
|
|
501
|
+
if not isinstance(compute_fallback, bool):
|
|
502
|
+
raise ValueError(
|
|
503
|
+
f"compute_fallback must be True or False, got {compute_fallback!r}"
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
if outcome_col not in df.columns:
|
|
507
|
+
raise ValueError(f"outcome_col={outcome_col!r} not found in df")
|
|
508
|
+
|
|
509
|
+
if primary_tiebreak not in ("gap", "rate"):
|
|
510
|
+
raise ValueError(
|
|
511
|
+
f"primary_tiebreak must be 'gap' or 'rate', got {primary_tiebreak!r}"
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
vals = set(pd.unique(df[outcome_col].dropna()))
|
|
515
|
+
if not vals.issubset({0, 1, True, False, 0.0, 1.0}):
|
|
516
|
+
raise ValueError(
|
|
517
|
+
f"outcome_col={outcome_col!r} must be binary (0/1 or True/False); "
|
|
518
|
+
f"found values {sorted(vals, key=str)}"
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
if columns is None:
|
|
522
|
+
columns = [
|
|
523
|
+
c for c in df.select_dtypes(include=[np.number]).columns
|
|
524
|
+
if c != outcome_col
|
|
525
|
+
]
|
|
526
|
+
|
|
527
|
+
if show_progress:
|
|
528
|
+
print(f"\n{'='*60}")
|
|
529
|
+
print(f" edgepoint.find | outcome_col={outcome_col!r} columns={columns}")
|
|
530
|
+
print(f" range_bins={range_bins} gap_threshold={gap_threshold:.0f}% "
|
|
531
|
+
f"target_pct={target_pct:.0f}% min_coverage_pct={min_coverage_pct:.0f}% "
|
|
532
|
+
f"fallback_tolerance_pct={fallback_tolerance_pct:.0f}% "
|
|
533
|
+
f"primary_tiebreak={primary_tiebreak!r} "
|
|
534
|
+
f"compute_fallback={compute_fallback}")
|
|
535
|
+
print(f"{'='*60}")
|
|
536
|
+
|
|
537
|
+
result = {}
|
|
538
|
+
|
|
539
|
+
for col in columns:
|
|
540
|
+
sub = df[[col, outcome_col]].dropna()
|
|
541
|
+
n = len(sub)
|
|
542
|
+
col_values = sub[col].to_numpy(dtype=float)
|
|
543
|
+
y = sub[outcome_col].astype(int).to_numpy()
|
|
544
|
+
|
|
545
|
+
if n < min_rows:
|
|
546
|
+
if show_progress:
|
|
547
|
+
print(f"\n [{col}] only {n} rows, need >= {min_rows} → skipping")
|
|
548
|
+
result[col] = None
|
|
549
|
+
continue
|
|
550
|
+
|
|
551
|
+
edges = _build_range_edges(col_values, range_bins)
|
|
552
|
+
if edges is None or len(edges) == 0:
|
|
553
|
+
if show_progress:
|
|
554
|
+
print(f"\n [{col}] no range to split (constant column) → skipping")
|
|
555
|
+
result[col] = None
|
|
556
|
+
continue
|
|
557
|
+
|
|
558
|
+
if show_progress:
|
|
559
|
+
print(f"\n [{col}] n={n} range=[{col_values.min():.4g} .. "
|
|
560
|
+
f"{col_values.max():.4g}] edges={len(edges)}")
|
|
561
|
+
print(f" checking every edge (min_coverage_pct={min_coverage_pct:.0f}% floor):")
|
|
562
|
+
|
|
563
|
+
result[col] = _scan_edges(
|
|
564
|
+
col_values, y, edges, show_progress,
|
|
565
|
+
compute_fallback,
|
|
566
|
+
gap_threshold=gap_threshold,
|
|
567
|
+
target_pct=target_pct,
|
|
568
|
+
min_coverage_pct=min_coverage_pct,
|
|
569
|
+
fallback_tolerance_pct=fallback_tolerance_pct,
|
|
570
|
+
primary_tiebreak=primary_tiebreak,
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
return result
|
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: edgepoint
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Find the point in a numeric column above which a binary outcome becomes meaningfully and reliably better.
|
|
5
|
+
Author: Henry
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: threshold,decision-threshold,binary-classification,feature-engineering,data-analysis,analytics,statistics,edge,threshold-detection
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Intended Audience :: Science/Research
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: numpy>=1.21
|
|
23
|
+
Requires-Dist: pandas>=1.5
|
|
24
|
+
Provides-Extra: test
|
|
25
|
+
Requires-Dist: pytest>=7.0; extra == "test"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
Dynamic: requires-python
|
|
28
|
+
|
|
29
|
+
# edgepoint
|
|
30
|
+
|
|
31
|
+
> Find the point in a numeric column above which a binary outcome becomes meaningfully and reliably better.
|
|
32
|
+
|
|
33
|
+
`edgepoint` is a lightweight Python library for discovering interpretable
|
|
34
|
+
thresholds in numeric data. Given a numeric feature and a binary outcome,
|
|
35
|
+
it answers one practical question:
|
|
36
|
+
|
|
37
|
+
> **"Where does this variable start becoming good enough?"**
|
|
38
|
+
|
|
39
|
+
Instead of fitting a predictive model, `edgepoint` searches for the point
|
|
40
|
+
where performance becomes good **without giving up more of the population
|
|
41
|
+
than necessary**. It favors thresholds that stay both **effective** and
|
|
42
|
+
**widely applicable**, rather than isolating a tiny group with exceptional
|
|
43
|
+
results. If no convincing threshold exists, it simply returns `None`.
|
|
44
|
+
|
|
45
|
+
No charts. No notebook. Hand it a DataFrame, get a number back, built to be
|
|
46
|
+
called by code, not stared at by a human.
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from edgepoint import find
|
|
50
|
+
|
|
51
|
+
profile = find(
|
|
52
|
+
df,
|
|
53
|
+
outcome_col="hit", # any binary (0/1 or True/False) column
|
|
54
|
+
compute_fallback=True, # mandatory: fall back to best-available split, or report None
|
|
55
|
+
)
|
|
56
|
+
# {"score": 66.75, "age": None, ...}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Where this applies
|
|
62
|
+
|
|
63
|
+
Any continuous metric + binary outcome pair, for example:
|
|
64
|
+
|
|
65
|
+
- **Marketing**: at what engagement score does churn risk drop off
|
|
66
|
+
- **Credit**: at what score does default rate become acceptable
|
|
67
|
+
- **Healthcare**: at what biomarker level does an outcome rate jump
|
|
68
|
+
- **Product**: at what usage count does upgrade-to-paid rate spike
|
|
69
|
+
- **Manufacturing**: at what sensor reading does defect rate climb
|
|
70
|
+
- **Trading / betting**: at what signal-strength value does a pick's hit
|
|
71
|
+
rate become reliable (the original use case this was built for)
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## What you need
|
|
76
|
+
|
|
77
|
+
You only need:
|
|
78
|
+
|
|
79
|
+
- One or more **numeric columns** to evaluate.
|
|
80
|
+
- One **binary outcome column** indicating whether each observation was a
|
|
81
|
+
success or not.
|
|
82
|
+
|
|
83
|
+
The outcome column may contain `0`/`1`, `True`/`False`, `0.0`/`1.0`, or any
|
|
84
|
+
mixture of the above:
|
|
85
|
+
|
|
86
|
+
| Numeric column | Outcome column |
|
|
87
|
+
| ------------------ | ------------------------------ |
|
|
88
|
+
| Credit score | Defaulted (`True` / `False`) |
|
|
89
|
+
| Engagement score | Converted (`1` / `0`) |
|
|
90
|
+
| Biomarker level | Improved (`True` / `False`) |
|
|
91
|
+
| Model confidence | Correct prediction (`1` / `0`) |
|
|
92
|
+
| Betting signal score | Win (`True` / `False`) |
|
|
93
|
+
| Risk score | Fraud (`1` / `0`) |
|
|
94
|
+
|
|
95
|
+
`edgepoint` searches each numeric column for the point above which the
|
|
96
|
+
outcome rate improves while still retaining a meaningful share of the
|
|
97
|
+
population.
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Install
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
pip install edgepoint
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from edgepoint import find
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Quick start
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
import pandas as pd
|
|
117
|
+
from edgepoint import find
|
|
118
|
+
|
|
119
|
+
df = pd.DataFrame({
|
|
120
|
+
"score": [
|
|
121
|
+
5, 8, 11, 14, 17, 20, 23, 26,
|
|
122
|
+
29, 32, 35, 38, 41, 44, 47, 50,
|
|
123
|
+
53, 56, 59, 62, 65, 68, 71, 74,
|
|
124
|
+
77, 80, 83, 86
|
|
125
|
+
],
|
|
126
|
+
"hit": [
|
|
127
|
+
0, 0, 1, 0, 1, 1, 0, 1,
|
|
128
|
+
1, 0, 1, 1, 0, 1, 1, 1,
|
|
129
|
+
1, 0, 1, 1, 1, 1, 1, 1,
|
|
130
|
+
0, 1, 1, 1
|
|
131
|
+
]
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
result = find(
|
|
135
|
+
df,
|
|
136
|
+
outcome_col="hit",
|
|
137
|
+
compute_fallback=True,
|
|
138
|
+
show_progress=True,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
print(result)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
```
|
|
145
|
+
============================================================
|
|
146
|
+
edgepoint.find | outcome_col='hit' columns=['score']
|
|
147
|
+
range_bins=15 gap_threshold=10% target_pct=60% min_coverage_pct=30% fallback_tolerance_pct=3% primary_tiebreak='gap' compute_fallback=True
|
|
148
|
+
============================================================
|
|
149
|
+
|
|
150
|
+
[score] n=28 range=[5 .. 86] edges=14
|
|
151
|
+
checking every edge (min_coverage_pct=30% floor):
|
|
152
|
+
edge=10.4 >= 10.4: n=26 rate=76.9% < 10.4: n=2 rate=0.0% gap=+76.9% coverage=93%
|
|
153
|
+
edge=15.8 >= 15.8: n=24 rate=79.2% < 15.8: n=4 rate=25.0% gap=+54.2% coverage=86%
|
|
154
|
+
edge=21.2 >= 21.2: n=22 rate=77.3% < 21.2: n=6 rate=50.0% gap=+27.3% coverage=79%
|
|
155
|
+
edge=26.6 >= 26.6: n=20 rate=80.0% < 26.6: n=8 rate=50.0% gap=+30.0% coverage=71%
|
|
156
|
+
edge=32 >= 32: n=19 rate=78.9% < 32: n=9 rate=55.6% gap=+23.4% coverage=68%
|
|
157
|
+
edge=37.4 >= 37.4: n=17 rate=82.4% < 37.4: n=11 rate=54.5% gap=+27.8% coverage=61%
|
|
158
|
+
edge=42.8 >= 42.8: n=15 rate=86.7% < 42.8: n=13 rate=53.8% gap=+32.8% coverage=54%
|
|
159
|
+
edge=48.2 >= 48.2: n=13 rate=84.6% < 48.2: n=15 rate=60.0% gap=+24.6% coverage=46%
|
|
160
|
+
edge=53.6 >= 53.6: n=11 rate=81.8% < 53.6: n=17 rate=64.7% gap=+17.1% coverage=39%
|
|
161
|
+
edge=59 >= 59: n=10 rate=90.0% < 59: n=18 rate=61.1% gap=+28.9% coverage=36%
|
|
162
|
+
edge=64.4 (coverage 29% < min_coverage_pct=30%) -> skipped
|
|
163
|
+
edge=69.8 (coverage 21% < min_coverage_pct=30%) -> skipped
|
|
164
|
+
edge=75.2 (coverage 14% < min_coverage_pct=30%) -> skipped
|
|
165
|
+
edge=80.6 (coverage 7% < min_coverage_pct=30%) -> skipped
|
|
166
|
+
-> PRIMARY: split at 10.4, gap=76.9%, above_rate=76.9%, coverage=93% (>= gap_threshold=10% AND target_pct=60%; tiebreak=gap)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Output:
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
{
|
|
173
|
+
"score": 10.4
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Meaning: scores of **10.4 and above** produce a noticeably higher hit rate
|
|
178
|
+
(76.9%) than scores below 10.4 (0.0%), while still covering 93% of the
|
|
179
|
+
population, well clear of every quality bar.
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## Using the result correctly
|
|
184
|
+
|
|
185
|
+
The value returned by `find()` is **not** intended to become a permanent
|
|
186
|
+
threshold. It is simply the best candidate based on the data available at
|
|
187
|
+
the time the function was called.
|
|
188
|
+
|
|
189
|
+
As more observations arrive, the best threshold may shift, strengthen,
|
|
190
|
+
weaken, or disappear entirely. A threshold that looked convincing today
|
|
191
|
+
may no longer be appropriate once more data has accumulated. This is
|
|
192
|
+
intentional.
|
|
193
|
+
|
|
194
|
+
`edgepoint` answers exactly one question:
|
|
195
|
+
|
|
196
|
+
> **"Given the data I have right now, where is the best candidate threshold?"**
|
|
197
|
+
|
|
198
|
+
It deliberately does **not** answer:
|
|
199
|
+
|
|
200
|
+
> **"How long should I trust this threshold?"**
|
|
201
|
+
|
|
202
|
+
That responsibility belongs to the system using this package. A typical
|
|
203
|
+
production workflow:
|
|
204
|
+
|
|
205
|
+
- recompute thresholds periodically, not once
|
|
206
|
+
- check more frequently while data is limited
|
|
207
|
+
- let tolerance relax as more evidence accumulates
|
|
208
|
+
- monitor performance over time
|
|
209
|
+
- automatically demote or remove thresholds that stop performing
|
|
210
|
+
|
|
211
|
+
Think of `find()` as proposing a threshold, not certifying one forever.
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## Multiple columns
|
|
216
|
+
|
|
217
|
+
By default, every numeric column except the outcome column is scanned.
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
result = find(
|
|
221
|
+
df,
|
|
222
|
+
outcome_col="converted",
|
|
223
|
+
compute_fallback=False,
|
|
224
|
+
)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
{
|
|
229
|
+
"age": None,
|
|
230
|
+
"income": 42000.0,
|
|
231
|
+
"engagement_score": 67.14,
|
|
232
|
+
"rating": 4.10
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Each column is evaluated independently.
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
## Scanning selected columns
|
|
241
|
+
|
|
242
|
+
```python
|
|
243
|
+
result = find(
|
|
244
|
+
df,
|
|
245
|
+
outcome_col="converted",
|
|
246
|
+
columns=["score", "income", "age"],
|
|
247
|
+
compute_fallback=True,
|
|
248
|
+
)
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Pass the full DataFrame and use `columns` as a whitelist, no need to
|
|
252
|
+
pre-slice the data yourself.
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
## Custom parameters
|
|
257
|
+
|
|
258
|
+
```python
|
|
259
|
+
result = find(
|
|
260
|
+
df,
|
|
261
|
+
outcome_col="converted",
|
|
262
|
+
compute_fallback=True,
|
|
263
|
+
gap_threshold=15,
|
|
264
|
+
target_pct=70,
|
|
265
|
+
min_coverage_pct=35,
|
|
266
|
+
range_bins=20,
|
|
267
|
+
primary_tiebreak="gap",
|
|
268
|
+
show_progress=True,
|
|
269
|
+
)
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
---
|
|
273
|
+
|
|
274
|
+
## Parameters
|
|
275
|
+
|
|
276
|
+
| Parameter | Required | Description |
|
|
277
|
+
| ------------------------ | -------- | --------------------------------------------------- |
|
|
278
|
+
| `outcome_col` | Yes | Binary outcome column |
|
|
279
|
+
| `compute_fallback` | Yes | Whether weaker fallback thresholds are allowed |
|
|
280
|
+
| `columns` | No | Columns to scan. Defaults to every numeric column |
|
|
281
|
+
| `gap_threshold` | No | Minimum improvement required, in percentage points |
|
|
282
|
+
| `target_pct` | No | Minimum success rate required, in % |
|
|
283
|
+
| `min_coverage_pct` | No | Minimum percentage of rows above the threshold |
|
|
284
|
+
| `range_bins` | No | Number of equal-width candidate edges |
|
|
285
|
+
| `fallback_tolerance_pct` | No | Near-tie tolerance, in percentage points |
|
|
286
|
+
| `primary_tiebreak` | No | `"gap"` or `"rate"` |
|
|
287
|
+
| `min_rows` | No | Minimum rows required before scanning a column |
|
|
288
|
+
| `show_progress` | No | Print every candidate edge while scanning |
|
|
289
|
+
|
|
290
|
+
See `edgepoint/core.py` for the complete parameter reference and defaults.
|
|
291
|
+
|
|
292
|
+
---
|
|
293
|
+
|
|
294
|
+
## Returns
|
|
295
|
+
|
|
296
|
+
`find()` returns a dictionary whose keys are column names and whose values
|
|
297
|
+
are the detected thresholds:
|
|
298
|
+
|
|
299
|
+
```python
|
|
300
|
+
{
|
|
301
|
+
"score": 63.8,
|
|
302
|
+
"income": 42000,
|
|
303
|
+
"age": None
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
A value of `None` means no threshold satisfied the requested criteria.
|
|
308
|
+
This is a legitimate, reportable result, not a gap to be patched over.
|
|
309
|
+
|
|
310
|
+
---
|
|
311
|
+
|
|
312
|
+
## Supported outcome values
|
|
313
|
+
|
|
314
|
+
The outcome column may contain:
|
|
315
|
+
|
|
316
|
+
- `0` / `1`
|
|
317
|
+
- `True` / `False`
|
|
318
|
+
- `0.0` / `1.0`
|
|
319
|
+
- any mixture of the above
|
|
320
|
+
|
|
321
|
+
Any other value (strings, `2`, `-1`, etc.) raises a `ValueError` before
|
|
322
|
+
any column is scanned, a full stop, not a per-column skip. One bad value
|
|
323
|
+
hiding anywhere in the outcome column, even among hundreds of otherwise
|
|
324
|
+
clean rows, halts the entire call.
|
|
325
|
+
|
|
326
|
+
---
|
|
327
|
+
|
|
328
|
+
## Inspecting a decision
|
|
329
|
+
|
|
330
|
+
```python
|
|
331
|
+
find(
|
|
332
|
+
df,
|
|
333
|
+
outcome_col="hit",
|
|
334
|
+
compute_fallback=True,
|
|
335
|
+
show_progress=True,
|
|
336
|
+
)
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
`show_progress=True` prints every candidate threshold as it is evaluated,
|
|
340
|
+
including coverage, outcome rates above and below the threshold,
|
|
341
|
+
improvement (gap), rejected candidates and why, and whether the primary or
|
|
342
|
+
fallback tier produced the final result. Useful for tuning parameters or
|
|
343
|
+
validating thresholds on new datasets.
|
|
344
|
+
|
|
345
|
+
---
|
|
346
|
+
|
|
347
|
+
## How it works
|
|
348
|
+
|
|
349
|
+
For every numeric column:
|
|
350
|
+
|
|
351
|
+
1. Determine the column's minimum and maximum values.
|
|
352
|
+
2. Divide that range into equal-width candidate edges.
|
|
353
|
+
3. Evaluate every candidate threshold: outcome rate above, outcome rate
|
|
354
|
+
below, improvement (gap), and coverage.
|
|
355
|
+
4. Reject candidates that fail the coverage floor.
|
|
356
|
+
5. Among the rest, select the best remaining threshold using a two-tier,
|
|
357
|
+
coverage-preferring tiebreak.
|
|
358
|
+
6. Return `None` if no convincing threshold exists.
|
|
359
|
+
|
|
360
|
+
The algorithm is deterministic: the same data with the same parameters
|
|
361
|
+
always produces the same result.
|
|
362
|
+
|
|
363
|
+
---
|
|
364
|
+
|
|
365
|
+
## Design journal
|
|
366
|
+
|
|
367
|
+
This section exists because most packages hide the *why* and only ship
|
|
368
|
+
the *what*. Every decision below was actually argued out before it was
|
|
369
|
+
built, including the ones that almost went a different way.
|
|
370
|
+
|
|
371
|
+
**Why not just use isotonic regression or a decision-tree split?**
|
|
372
|
+
Both exist and both are more "textbook optimal" in a narrow sense.
|
|
373
|
+
Decision trees optimize prediction; they don't automatically enforce
|
|
374
|
+
minimum coverage, minimum success rate, minimum improvement, or stable
|
|
375
|
+
tiebreaking among near-ties, those are left to whoever builds the model.
|
|
376
|
+
Isotonic regression estimates a monotonic probability curve, but doesn't
|
|
377
|
+
directly answer "where should the operational threshold sit," you still
|
|
378
|
+
have to decide where "good enough" begins. Neither method has a coverage
|
|
379
|
+
guard by default, so both will happily hand you a "great" split sitting
|
|
380
|
+
on 8 rows. If you're fluent enough in ML to reach for `optbinning` or
|
|
381
|
+
`sklearn`, you don't strictly need this package to solve the raw math.
|
|
382
|
+
What you get here instead is the guard logic and the fallback decision
|
|
383
|
+
made explicit, visible, and enforced, not bolted on after the fact.
|
|
384
|
+
|
|
385
|
+
**Why equal-width bins instead of quantile bins?**
|
|
386
|
+
Quantile (`qcut`) edges are population-based, they guarantee similar row
|
|
387
|
+
count per step, not similar value range. On a column with a lot of
|
|
388
|
+
duplicate or clustered values, quantile edges collapse toward the crowded
|
|
389
|
+
region, and a real effect sitting right at that cluster gets buried inside
|
|
390
|
+
one wide edge alongside completely different values. Equal-width edges
|
|
391
|
+
are spaced evenly across the actual range, so a cluster still gets a
|
|
392
|
+
nearby edge to split on.
|
|
393
|
+
|
|
394
|
+
**Why coverage instead of range-position as the guard?**
|
|
395
|
+
The first version of this logic used a range-position cutoff (a max
|
|
396
|
+
percentile of the range) to avoid thin slivers. That turned out to be
|
|
397
|
+
only a *proxy* for the real thing being protected against, on a skewed
|
|
398
|
+
column, range-position and actual row coverage decouple. An edge sitting
|
|
399
|
+
at 67% of the way across the range can still cover only 4% of rows, while
|
|
400
|
+
an edge at 20% across can cover 91%. Checking coverage directly, against
|
|
401
|
+
real row counts, is the more honest guard.
|
|
402
|
+
|
|
403
|
+
**Why is `compute_fallback` mandatory, with no default?**
|
|
404
|
+
Because whether a column should ever settle for a "best available, but
|
|
405
|
+
weaker" split instead of reporting `None` is a modeling decision, not
|
|
406
|
+
something that should silently default one way forever. Omitting it
|
|
407
|
+
raises a `ValueError` on purpose. You have to consciously choose `True`
|
|
408
|
+
or `False` every time.
|
|
409
|
+
|
|
410
|
+
**Should this punish a range with many rows and a bad rate, and not
|
|
411
|
+
over-trust a range with few rows and a good rate, automatically, like a
|
|
412
|
+
promotion system?**
|
|
413
|
+
This came up directly: the instinct was "a new signal showing promise
|
|
414
|
+
should get gradually promoted, not fully trusted on day one, the way a
|
|
415
|
+
new hire earns trust over time." The honest answer: yes, that's a real
|
|
416
|
+
mechanism (formally, a Wilson-lower-bound-style confidence penalty that
|
|
417
|
+
scales with row count), but it does **not** belong inside this function.
|
|
418
|
+
This function's job is to propose a candidate spot on the data it's given
|
|
419
|
+
right now, a single, static, one-shot check. The gradual-trust mechanism
|
|
420
|
+
belongs in whatever *live* system calls this repeatedly over time: check
|
|
421
|
+
sooner when data is thin, widen tolerance as more data accumulates, and
|
|
422
|
+
let a live feedback loop auto-correct a promoted spot that stops
|
|
423
|
+
performing. That reasons about performance as data keeps arriving, not
|
|
424
|
+
just at one static moment, which is more information than any confidence
|
|
425
|
+
interval computed once could ever give you. So: the coverage floor stays
|
|
426
|
+
a hard gate here, and the graduated-trust logic stays out of this
|
|
427
|
+
package, on purpose, it's where your own orchestration layer earns its
|
|
428
|
+
keep, and it's arguably the more proprietary part worth keeping close
|
|
429
|
+
anyway.
|
|
430
|
+
|
|
431
|
+
**Does it only find a "good" zone, or does it independently verify the
|
|
432
|
+
"bad" zone too?**
|
|
433
|
+
One-directional, by design. This function only ever asks "is there a
|
|
434
|
+
point above which the rate is *better* than below it?" It does not
|
|
435
|
+
independently score the below-side against its own bar, it only knows
|
|
436
|
+
that "below" is worse than "above," which is a comparative statement, not
|
|
437
|
+
two independently-verified zones. In practice those usually amount to the
|
|
438
|
+
same thing, but it's worth being precise about what's actually being
|
|
439
|
+
claimed.
|
|
440
|
+
|
|
441
|
+
**Is this reinventing the wheel?**
|
|
442
|
+
Partly, and that's fine to say plainly. The underlying math (walk a
|
|
443
|
+
range, find a threshold, compare two groups) isn't novel. What isn't
|
|
444
|
+
already sitting in a well-known library is the specific combination of an
|
|
445
|
+
explicit coverage floor, a forced fallback decision, a tolerance-based
|
|
446
|
+
tiebreak that consistently prefers coverage over noise, and full per-edge
|
|
447
|
+
transparency via `show_progress`, every rejected edge and *why* it was
|
|
448
|
+
rejected, not just the final answer. That combination is what makes it
|
|
449
|
+
something you can actually audit, which matters more than raw statistical
|
|
450
|
+
optimality in a system where a human has to trust and act on the output.
|
|
451
|
+
|
|
452
|
+
---
|
|
453
|
+
|
|
454
|
+
## Design decisions, summarized
|
|
455
|
+
|
|
456
|
+
- **Equal-width candidate edges** avoid dense regions dominating the
|
|
457
|
+
search.
|
|
458
|
+
- **Coverage guard** measured directly with row counts, not percentile
|
|
459
|
+
position, so tiny high-performing groups are never over-trusted.
|
|
460
|
+
- **Mandatory fallback decision**, `compute_fallback` has no default,
|
|
461
|
+
because whether weaker thresholds are acceptable is a modeling call
|
|
462
|
+
you must make explicitly every time.
|
|
463
|
+
- **Honest `None`**, when no convincing threshold exists, that's reported
|
|
464
|
+
as a legitimate outcome, not forced into a weak recommendation.
|
|
465
|
+
- **Coverage-first tiebreaking**, among near-identical candidates, the
|
|
466
|
+
broader-coverage threshold wins.
|
|
467
|
+
- **One-directional search**, asks only "above what value does the
|
|
468
|
+
outcome become better," and does not independently validate the
|
|
469
|
+
below-threshold region as its own zone.
|
|
470
|
+
|
|
471
|
+
---
|
|
472
|
+
|
|
473
|
+
## Who this is for
|
|
474
|
+
|
|
475
|
+
Not primarily ML researchers with `optbinning` already in their toolbox,
|
|
476
|
+
they don't need this to solve the raw math. This is for people closer to
|
|
477
|
+
how it was actually built: solo builders, indie quants, and analysts who
|
|
478
|
+
would rather read and trust 100 lines they can fully audit than hand a
|
|
479
|
+
threshold decision to a library's internals. If a chart in a notebook is
|
|
480
|
+
your normal workflow, this is the same idea, minus the human staring at
|
|
481
|
+
the chart, a `df` in, a structured answer out, ready to be called from
|
|
482
|
+
inside a live system, not just eyeballed once and forgotten.
|
|
483
|
+
|
|
484
|
+
---
|
|
485
|
+
|
|
486
|
+
## FAQ
|
|
487
|
+
|
|
488
|
+
**Does this replace machine learning?**
|
|
489
|
+
No. Machine learning predicts outcomes; `edgepoint` proposes interpretable
|
|
490
|
+
operational thresholds. Different problem.
|
|
491
|
+
|
|
492
|
+
**Is this statistically optimal?**
|
|
493
|
+
Not necessarily, and not the goal. It intentionally prioritizes
|
|
494
|
+
interpretability and operational usefulness over optimizing a pure
|
|
495
|
+
statistical objective.
|
|
496
|
+
|
|
497
|
+
**Why can the function return `None`?**
|
|
498
|
+
Because not every variable contains a convincing threshold. Returning
|
|
499
|
+
`None` is more honest than forcing a weak recommendation.
|
|
500
|
+
|
|
501
|
+
**Should I hard-code the threshold?**
|
|
502
|
+
No. Treat it as a snapshot of your current data and recompute as new
|
|
503
|
+
observations arrive. See "Using the result correctly" above.
|
|
504
|
+
|
|
505
|
+
---
|
|
506
|
+
|
|
507
|
+
## Naming note
|
|
508
|
+
|
|
509
|
+
This package went through a few naming iterations before landing here,
|
|
510
|
+
early candidates included `optimal_start`, `sweet_spot`, `sweetspot`, and
|
|
511
|
+
`maxspot`, each rejected either for not capturing the actual idea (a
|
|
512
|
+
zone/point where rate improves without sacrificing population) or for
|
|
513
|
+
colliding with an existing PyPI package. `edgepoint` was checked and
|
|
514
|
+
confirmed clear, and both the import and the PyPI distribution name now
|
|
515
|
+
match, so there's no split between what you `pip install` and what you
|
|
516
|
+
`import`.
|
|
517
|
+
|
|
518
|
+
---
|
|
519
|
+
|
|
520
|
+
## License
|
|
521
|
+
|
|
522
|
+
MIT
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
edgepoint/__init__.py,sha256=cv0XIsh3sFKz0leI0KYM72O_pB_lcshBV6bwFr6ydPg,397
|
|
2
|
+
edgepoint/core.py,sha256=ZGRvt39btoEqxR1tk23bfwVo-uSEVe8eAsRUhbS2f_g,25925
|
|
3
|
+
edgepoint-1.0.0.dist-info/licenses/LICENSE,sha256=NXS3_dg3gkUImu9hvTxxZ_27yzdjKvukLtGTL6kN0HM,1076
|
|
4
|
+
edgepoint-1.0.0.dist-info/METADATA,sha256=M_kAPCLlUb7KmQ81ANhYG05DJfcgHC64D0cw3DJH2No,19792
|
|
5
|
+
edgepoint-1.0.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
6
|
+
edgepoint-1.0.0.dist-info/top_level.txt,sha256=Vlii-3A5Kg43pq--dmwzHoFaDPcnQOt2cZ2BB9R9zlM,10
|
|
7
|
+
edgepoint-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 osas2henry@gmail.com
|
|
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
|
+
edgepoint
|