edgepoint 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,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