edgepoint 2.0.1__tar.gz → 2.0.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: edgepoint
3
- Version: 2.0.1
3
+ Version: 2.0.3
4
4
  Summary: Find the point in a numeric column above which a binary outcome becomes meaningfully and reliably better.
5
5
  Author: Henry
6
6
  License: MIT
@@ -38,11 +38,13 @@ outcome, it answers a practical question, twice:
38
38
  > becoming good enough while keeping the best coverage possible, and
39
39
  > does that still hold on unseen data?"**
40
40
 
41
- Rather than fitting a predictive model, `edgepoint` splits your data into
42
- train and test, searches train for the best threshold(s), then replays those
43
- exact thresholds against test with no re-optimization. What you get back is
44
- not just "the best split we could find" but "the best split we could find,
45
- and here's how it actually performed on data it never touched."
41
+ `edgepoint` makes a simple, less-savvy-visible property of your data easy
42
+ to see: the exact point along a metric where an outcome starts spiking.
43
+ `edgepoint` splits your data into train and test, searches train for the
44
+ best threshold(s), then replays those exact thresholds against test with
45
+ no re-optimization. What you get back is not just "the best split we could
46
+ find" but "the best split we could find, and here's how it actually
47
+ performed on data it never touched."
46
48
 
47
49
  ```python
48
50
  from edgepoint import search
@@ -60,6 +62,52 @@ below.
60
62
 
61
63
  ---
62
64
 
65
+ ## `outcome_col`: telling edgepoint what "good" means
66
+
67
+ Before `search()` scans a single edge, it needs one thing from you that
68
+ matters more than any parameter: `outcome_col`. This is you, the analyst,
69
+ labeling every row in your data as a win or a loss, a pass or a fail, a
70
+ positive or a negative. `edgepoint` doesn't decide what "success" looks
71
+ like in your business, it has no idea what a good sale, a good patient
72
+ outcome, or a good bet even is. You decide that, once, by pointing at a
73
+ column, and every other number the library produces is downstream of that
74
+ one judgment.
75
+
76
+ Concretely: `outcome_col` must be a column of `1`/`0`, `True`/`False`, or
77
+ a mix of both, one value per row, `1` (or `True`) meaning "this row counts
78
+ as a success" and `0` (or `False`) meaning "this row doesn't." That's it,
79
+ no other values are allowed, `edgepoint` raises a `ValueError` before doing
80
+ any work if it finds anything else in that column, rather than silently
81
+ guessing which values you meant as the "good" ones.
82
+
83
+ Once that column exists, everything else in `search()` is really just
84
+ counting, over and over, at every candidate edge in every numeric column:
85
+ *"of the rows at or above this point, what fraction were a `1`?"* That
86
+ fraction is `hit_rate`. The rest, `miss_rate`, `gap`, `coverage`, `score`,
87
+ are all built directly on top of that one count. Get `outcome_col` wrong,
88
+ too loose, too strict, mislabeled, or measuring the wrong thing entirely,
89
+ and every threshold `search()` proposes will be confidently optimized for
90
+ the wrong target. Get it right, and the rest of the pipeline (percentile
91
+ edges, train/test replay, combo search) is just honest, mechanical counting
92
+ against a target you already trust.
93
+
94
+ A few practical notes on choosing it:
95
+
96
+ - **It has to already exist as a column.** If "success" in your data is a
97
+ derived condition, e.g. "sale closed within 7 days" or "score improved by
98
+ at least 10 points", compute that as its own `0`/`1` column *before*
99
+ calling `search()`. `edgepoint` labels nothing on its own.
100
+ - **One outcome per run.** If you care about two different definitions of
101
+ success (say, "converted" and "converted *and* retained"), that's two
102
+ separate calls to `search()` with two different `outcome_col`s, not one
103
+ call trying to do both.
104
+ - **Rare outcomes still need rows on both sides.** If almost every row is a
105
+ `1` (or almost every row is a `0`), there's very little contrast for
106
+ `edgepoint` to find an edge across, and columns are likely to get skipped
107
+ for failing `min_coverage` on whichever side is thin.
108
+
109
+ ---
110
+
63
111
  ## Where this applies
64
112
 
65
113
  Any continuous metric (or several) + binary outcome pair, for example:
@@ -77,15 +125,9 @@ Any continuous metric (or several) + binary outcome pair, for example:
77
125
 
78
126
  ## What you need
79
127
 
80
- You only need:
81
-
82
- - One or more **numeric columns** to evaluate.
83
- - One **binary outcome column** indicating whether each observation was a
84
- success or not.
85
-
86
- The outcome column may contain `0`/`1`, `True`/`False`, `0.0`/`1.0`, or any
87
- mixture of the above. Any other value (strings, `2`, `-1`, etc.) raises a
88
- `ValueError` up front, before anything runs.
128
+ You only need two things: one or more **numeric columns** to evaluate, and
129
+ the **`outcome_col`** described above, your `0`/`1` (or `True`/`False`)
130
+ judgment of what counts as a win on each row. A few example pairings:
89
131
 
90
132
  | Numeric column | Outcome column |
91
133
  | --------------------- | ------------------------------- |
@@ -147,7 +189,6 @@ df = pd.DataFrame({
147
189
  })
148
190
  ```
149
191
 
150
-
151
192
  ### Running it
152
193
 
153
194
  ```python
@@ -156,15 +197,19 @@ from edgepoint import search
156
197
  train_results_df, test_results_df, top_combos = search(
157
198
  df,
158
199
  outcome_col="hit",
159
- mode="quick",
200
+ train_split=60, # 24 train / 16 test → both ≥ min_rows
201
+ min_rows=15,
160
202
  min_coverage=20,
203
+ mode="quick",
161
204
  top_combos_n=2,
162
- show_progress=True,
205
+ show_progress=False,
163
206
  )
164
207
  ```
165
208
 
166
- Running that prints a five-stage progress log (suppress it with
167
- `show_progress=False`):
209
+ With `show_progress=False` the call above runs silently and just returns
210
+ the three values. Turning `show_progress=True` on the exact same call
211
+ prints the full five-stage log so you can see how the split, the
212
+ single-column search, and the combo search actually got there:
168
213
 
169
214
  ```
170
215
  EDGEPOINT.
@@ -173,101 +218,117 @@ EDGEPOINT.
173
218
  🟡 STANDARD > 3 mins
174
219
  🔴 DEEP > 10 mins
175
220
 
176
- start : 2026-07-16 22:28:05.
221
+ start : 2026-07-17 07:10:14.
177
222
 
178
223
  [1/5] Train / Test Split
179
224
  --------------------------------
180
225
 
181
- train n=140. test n=60. total n=200.
226
+ Sample Size (n) - Full = 40 . Train = 24 . Test = 16 .
227
+
228
+ Hit Rate (%) - Full = 47.50. Train = 50.00. Test = 43.75.
182
229
 
183
- done in 0mins 0 secs | elapsed 0mins 1 secs | 22:28:06.
230
+ done in 0mins 0 secs | elapsed 0mins 1 secs | 07:10:15.
184
231
 
185
232
 
186
- [2/5] Single-Column Search (train, n=140)
233
+ [2/5] Single-Column Search (train, n=24)
187
234
  --------------------------------
188
235
 
189
- column edge_point hit_rate miss_rate gap n coverage
190
- 0 score 65.67 92.11 7.89 84.21 38 27.14
191
- 1 age 18.00 43.57 56.43 -12.86 140 100.00
236
+ column edge_point hit_rate miss_rate gap n coverage
237
+ 0 score 49.46 75.00 25.00 50.00 16 66.67
238
+ 1 age 39.50 57.14 42.86 14.29 14 58.33
192
239
 
193
- done in 0mins 0 secs | elapsed 0mins 1 secs | 22:28:06.
240
+ done in 0mins 0 secs | elapsed 0mins 1 secs | 07:10:16.
194
241
 
195
242
 
196
- [3/5] Single-Column Evaluation (test, n=60)
243
+ [3/5] Single-Column Evaluation (test, n=16)
197
244
  --------------------------------
198
245
 
199
- column edge_point hit_rate miss_rate gap n coverage
200
- 0 score 65.67 80.00 20.00 60.00 20 33.33
201
- 1 age 18.00 41.67 58.33 -16.67 60 100.00
246
+ column edge_point hit_rate miss_rate gap n coverage
247
+ 0 score 49.46 77.78 22.22 55.56 9 56.25
248
+ 1 age 39.50 42.86 57.14 -14.29 7 43.75
202
249
 
203
- done in 0mins 0 secs | elapsed 0mins 2 secs | 22:28:07.
250
+ done in 0mins 0 secs | elapsed 0mins 2 secs | 07:10:16.
204
251
 
205
252
 
206
253
  [4/5] Combo Generation & Evaluation (train, mode='quick') ..This may take a while.
207
254
  --------------------------------
208
255
 
209
- 2 combo(s) qualified (train).
256
+ 3 combo(s) qualified (train). sample size (n) = 24. hit rate (%) = 50.00.
210
257
 
211
258
  combo_num combo hit_rate miss_rate gap n coverage
212
- 0 1 score 92.11 7.89 84.21 38 27.14
213
- 1 3 score, age 92.11 7.89 84.21 38 27.14
259
+ 0 3 score, age 80.00 20.00 60.00 10 41.67
260
+ 1 1 score 75.00 25.00 50.00 16 66.67
261
+ 2 2 age 57.14 42.86 14.29 14 58.33
214
262
 
215
- done in 0mins 0 secs | elapsed 0mins 2 secs | 22:28:08.
263
+ done in 0mins 0 secs | elapsed 0mins 2 secs | 07:10:17.
216
264
 
217
265
 
218
266
  [5/5] Combo Validation (test, mode='quick')
219
267
  --------------------------------
220
268
 
221
- 2 combo(s) qualified (test).
269
+ 3 combo(s) qualified (test). sample size (n) = 16. hit rate (%) = 43.75.
222
270
 
223
- combo_num combo hit_rate coverage n train_hit_rate train_coverage score
224
- 0 1 score 80.00 33.33 20 92.11 27.14 1.0
225
- 1 3 score, age 80.00 33.33 20 92.11 27.14 1.0
271
+ combo_num combo hit_rate coverage n train_hit_rate train_coverage score
272
+ 0 3 score, age 60.00 31.25 5 80.00 41.67 21.49
273
+ 1 1 score 77.78 56.25 9 75.00 66.67 1.00
274
+ 2 2 age 42.86 43.75 7 57.14 58.33 -29.50
226
275
 
227
- done in 0mins 0 secs | elapsed 0mins 3 secs | 22:28:08.
276
+ done in 0mins 0 secs | elapsed 0mins 3 secs | 07:10:17.
228
277
 
229
278
 
230
279
  TOP 2 COMBOS (test)
231
280
  --------------------------------
232
- : combo #1 combo #3
233
- score : 65.67 65.67
234
- age : - 18.0
281
+
282
+ Sample Size (n) - Full = 40 . Train = 24 . Test = 16 .
283
+ Hit Rate (%) - Full = 47.50. Train = 50.00. Test = 43.75.
284
+
285
+ : combo #3 combo #1
286
+ Score : 49.46 49.46
287
+ Age : 39.5 -
235
288
  ......................................................
236
- hit_rate : 80.0 80.0
237
- coverage : 33.33 33.33
238
- train_hit_rate : 92.11 92.11
239
- train_coverage : 27.14 27.14
240
- combo_sample_size : 20 20
241
- test_sample_size : 60 60
242
-
243
- COMPLETE - total duration 0mins 4 secs (end 22:28:09).
289
+ Hit_rate : 60.0 77.78
290
+ Coverage : 31.25 56.25
291
+ Train_hit_rate : 80.0 75.0
292
+ Train_coverage : 41.67 66.67
293
+ Combo_sample_size : 5 9
294
+ Test_sample_size : 16 16
295
+ Baseline_hit_rate (full df) : 47.50 47.50
296
+ Vs_baseline : 12.50 + 30.28 +
297
+
298
+ COMPLETE - total duration 0mins 4 secs (end 07:10:18).
244
299
  ```
245
300
 
246
301
  And the three values `search()` returns:
247
302
 
248
303
  ```python
249
304
  >>> train_results_df
250
- column edge_point hit_rate miss_rate gap n coverage
251
- 0 score 65.67 92.11 7.89 84.21 38 27.14
252
- 1 age 18.00 43.57 56.43 -12.86 140 100.00
305
+ column edge_point hit_rate miss_rate gap n coverage
306
+ 0 score 49.46 75.00 25.00 50.00 16 66.67
307
+ 1 age 39.50 57.14 42.86 14.29 14 58.33
253
308
 
254
309
  >>> test_results_df
255
- column edge_point hit_rate miss_rate gap n coverage
256
- 0 score 65.67 80.00 20.00 60.00 20 33.33
257
- 1 age 18.00 41.67 58.33 -16.67 60 100.00
310
+ column edge_point hit_rate miss_rate gap n coverage
311
+ 0 score 49.46 77.78 22.22 55.56 9 56.25
312
+ 1 age 39.50 42.86 57.14 -14.29 7 43.75
258
313
 
259
314
  >>> top_combos
260
- [{'combo_num': 1, 'edges': {'score': 65.67}, 'hit_rate': 80.0, 'coverage': 33.33,
261
- 'train_hit_rate': 92.11, 'train_coverage': 27.14, 'combo_sample_size': 20, 'test_sample_size': 60},
262
- {'combo_num': 3, 'edges': {'score': 65.67, 'age': 18.0}, 'hit_rate': 80.0, 'coverage': 33.33,
263
- 'train_hit_rate': 92.11, 'train_coverage': 27.14, 'combo_sample_size': 20, 'test_sample_size': 60}]
315
+ [{'combo_num': 3, 'edges': {'score': 49.46, 'age': 39.5}, 'hit_rate': 60.0, 'coverage': 31.25,
316
+ 'train_hit_rate': 80.0, 'train_coverage': 41.67, 'combo_sample_size': 5, 'test_sample_size': 16,
317
+ 'baseline_hit_rate': 47.5, 'vs_baseline': 12.5},
318
+ {'combo_num': 1, 'edges': {'score': 49.46}, 'hit_rate': 77.78, 'coverage': 56.25,
319
+ 'train_hit_rate': 75.0, 'train_coverage': 66.67, 'combo_sample_size': 9, 'test_sample_size': 16,
320
+ 'baseline_hit_rate': 47.5, 'vs_baseline': 30.28}]
264
321
  ```
265
322
 
266
- Reading this: `score`'s threshold of 65.67 looked strong on train (92.11%
267
- hit rate over 27.14% of rows) and, replayed unchanged against test, held up
268
- at 80.00% over 33.33% of rows, a real but expected drop, not a collapse.
269
- Adding `age` to the combo changed nothing here (its own threshold barely
270
- qualified on train), which is itself a useful, honest result.
323
+ Reading this: `score`'s threshold of 49.46 looked solid on train (75.00%
324
+ hit rate over 66.67% of rows) and, replayed unchanged against test, held up
325
+ even better at 77.78% over 56.25% of rows, comfortably ahead of the 47.5%
326
+ baseline hit rate. Adding `age` into the combo (`score >= 49.46 AND age >=
327
+ 39.5`) tightens things further on train (80.00% hit rate) but shrinks
328
+ coverage a lot (down to 5 rows on test), and its test hit rate of 60.00%,
329
+ while still above baseline, is the less reliable of the two picks precisely
330
+ because it's resting on so few rows, exactly the kind of honest trade-off
331
+ `search()` is built to surface rather than hide.
271
332
 
272
333
  ---
273
334
 
@@ -384,14 +445,16 @@ list of up to `top_combos_n` dicts otherwise. Each dict:
384
445
 
385
446
  ```python
386
447
  {
387
- "combo_num": 1,
388
- "edges": {"score": 65.67}, # {column: edge_point} used by this combo
389
- "hit_rate": 80.0, # test-side
390
- "coverage": 33.33, # test-side
391
- "train_hit_rate": 92.11, # carried over from train, unchanged
392
- "train_coverage": 27.14, # carried over from train, unchanged
393
- "combo_sample_size": 20, # rows clearing the combo on test
394
- "test_sample_size": 60, # total test rows
448
+ "combo_num": 3,
449
+ "edges": {"score": 49.46, "age": 39.5}, # {column: edge_point} used by this combo
450
+ "hit_rate": 60.0, # test-side
451
+ "coverage": 31.25, # test-side
452
+ "train_hit_rate": 80.0, # carried over from train, unchanged
453
+ "train_coverage": 41.67, # carried over from train, unchanged
454
+ "combo_sample_size": 5, # rows clearing the combo on test
455
+ "test_sample_size": 16, # total test rows
456
+ "baseline_hit_rate": 47.5, # "do nothing" hit rate on the full df
457
+ "vs_baseline": 12.5, # test hit_rate - baseline_hit_rate, in points
395
458
  }
396
459
  ```
397
460
 
@@ -482,22 +545,64 @@ independently verified zones.
482
545
 
483
546
  ---
484
547
 
548
+ ## A good alternative choice, at any data size
549
+
550
+ This isn't tied to a data size at all, it scales from a few dozen rows up
551
+ to millions the same way: scan every candidate point, count what's above
552
+ and below it, replay the winner on held-out data. The core idea holds no
553
+ matter how much data feeds it.
554
+
555
+ **The core idea: make the spike visible.** Most datasets have some point
556
+ along a metric where the outcome rate stops being flat and starts climbing,
557
+ but that point is easy to miss just eyeballing a column. `edgepoint` scans
558
+ every candidate point along every numeric column and surfaces exactly
559
+ where that climb happens, `edge_point`, `hit_rate`, `gap`, `coverage`, in a
560
+ plain table. No hidden weights, no coefficients to decode, just "here's
561
+ the row count on each side of this line, and here's how much better things
562
+ get above it."
563
+
564
+ **Built to keep re-checking rather than lock in.** Data keeps arriving,
565
+ and a good threshold today can shift as more of it comes in. Rather than
566
+ fitting one answer and treating it as settled, `search()` is meant to be
567
+ re-run as new rows accumulate, each run rechecks the spike against a
568
+ fresh train/test split and tells you plainly if it moved, held, or
569
+ disappeared. See "Using the result correctly" above, that habit of
570
+ recomputing is what keeps the picture honest over time, regardless of
571
+ how many rows are behind it.
572
+
573
+ **Worth knowing on any dataset.** A good-looking gap can sometimes be a
574
+ handful of rows doing all the work rather than a real, reliable spike,
575
+ this shows up more easily on a smaller slice of data, but the same logic
576
+ applies at any scale. `edgepoint` leans against this two ways:
577
+ `min_coverage` refuses to let a threshold win on too few rows in the
578
+ first place, and the built-in shrinkage discount (see Design notes)
579
+ discounts small-sample gaps before they're allowed to win. Still worth a
580
+ glance at the `n` column yourself, if a combo's win is resting on very
581
+ few rows relative to the rest of your data, that's exactly the kind of
582
+ thing the recompute habit above is there to confirm or correct as more
583
+ data comes in.
584
+
585
+ ---
586
+
485
587
  ## Who this is for
486
588
 
487
- Not primarily ML researchers with an established modeling toolbox already
488
- in hand, they don't need this to solve the raw math. This is for people
489
- closer to how it was actually built: solo builders, indie quants, and
490
- analysts who would rather read and trust code they can fully audit than
491
- hand a threshold decision to a library's internals, and who want a basic
492
- train/test sanity check built in rather than bolted on separately.
589
+ Solo builders, indie quants, and analysts who want to see exactly where a
590
+ metric's outcome rate spikes, in a table they can read and trust at a
591
+ glance, without needing a modeling toolbox to get there. This is for people
592
+ closer to how it was actually built: readers who'd rather audit code they
593
+ fully understand than hand a threshold decision to a library's internals,
594
+ and who want a basic train/test sanity check built in rather than bolted
595
+ on separately.
493
596
 
494
597
  ---
495
598
 
496
599
  ## FAQ
497
600
 
498
- **Does this replace machine learning?**
499
- No. Machine learning predicts outcomes; `edgepoint` proposes interpretable
500
- operational thresholds and checks them against held-out data.
601
+ **What does edgepoint actually give me?**
602
+ A plain, readable table of exactly where an outcome starts spiking along
603
+ a metric, plus proof that the spike held up on data it hadn't seen. No
604
+ coefficients or hidden weights to decode, just an edge point, a hit rate,
605
+ and a coverage number.
501
606
 
502
607
  **Is this statistically optimal?**
503
608
  Not necessarily, and not the goal. It prioritizes interpretability and