Find the point in a numeric column above which a binary outcome becomes meaningfully and reliably better.
Project description
edgepoint
Find the point in a numeric column above which a binary outcome becomes meaningfully and reliably better, then check whether that finding actually holds up on data it never saw.
edgepoint is a lightweight Python library for discovering interpretable
thresholds in numeric data. Given one or more numeric features and a binary
outcome, it answers a practical question, twice:
"Where does this metric, or this combination of metrics, start becoming good enough while keeping the best coverage possible, and does that still hold on unseen data?"
Rather than fitting a predictive model, edgepoint splits your data into
train and test, searches train for the best threshold(s), then replays those
exact thresholds against test with no re-optimization. What you get back is
not just "the best split we could find" but "the best split we could find,
and here's how it actually performed on data it never touched."
from edgepoint import search
train_results_df, test_results_df, top_combos = search(
df,
outcome_col="hit", # any binary (0/1 or True/False) column
)
train_results_df and test_results_df are one-row-per-column tables of
single-variable thresholds; top_combos is the best AND-combination(s) of
those thresholds, scored and validated the same way. All three are described
below.
Where this applies
Any continuous metric (or several) + binary outcome pair, for example:
- Marketing: at what engagement score does churn risk drop off
- Credit: at what score does default rate become acceptable
- Healthcare: at what biomarker level does an outcome rate jump
- Product: at what usage count does upgrade-to-paid rate spike
- Manufacturing: at what sensor reading does defect rate climb
- Trading / betting: at what signal-strength value (or combination of signals) does a pick's hit rate become reliable (the original use case this was built for)
What you need
You only need:
- One or more numeric columns to evaluate.
- One binary outcome column indicating whether each observation was a success or not.
The outcome column may contain 0/1, True/False, 0.0/1.0, or any
mixture of the above. Any other value (strings, 2, -1, etc.) raises a
ValueError up front, before anything runs.
| Numeric column | Outcome column |
|---|---|
| Credit score | Defaulted (True / False) |
| Engagement score | Converted (1 / 0) |
| Biomarker level | Improved (True / False) |
| Model confidence | Correct prediction (1 / 0) |
| Betting signal score | Win (True / False) |
| Risk score | Fraud (1 / 0) |
Install
pip install edgepoint
from edgepoint import search
Quick start
Sample data
Everything below runs on this exact dataset, 40 rows of a score and an
age column against a binary hit outcome, hit was generated so that it
depends mostly on score (higher score, better odds) with age added in as
mostly noise. Build it directly as a DataFrame and follow along:
import pandas as pd
df = pd.DataFrame({
"score": [
72.68, 58.92, 62.15, 8.13, 33.3, 66.19, 73.79, 35.43,
65.06, 63.13, 61.47, 45.13, 39.16, 38.15, 29.41, 59.91,
54.12, 86.35, 76.02, 63.13, 51.63, 0, 77.4, 81.66,
50.13, 52.4, 52.82, 86.12, 57.58, 13.66, 37.29, 32.91,
73.66, 96.2, 36.3, 22.74, 65.54, 23.98, 61.59, 32.27
],
"age": [
33.9, 41.9, 30.8, 56.5, 45.8, 45.1, 43.3, 54.3,
52.5, 54.2, 43.9, 18, 31.3, 38, 48.3, 55.8,
35.4, 56, 20.2, 27.7, 28.1, 39.4, 44.3, 39.9,
53.6, 26.1, 63.1, 42.3, 46.3, 20.2, 39.2, 35.3,
27.7, 31.8, 26.5, 32.2, 35.1, 43.5, 45.8, 43.8
],
"hit": [
1, 1, 0, 0, 0, 1, 1, 0,
0, 1, 1, 0, 0, 0, 0, 1,
1, 1, 1, 1, 0, 0, 0, 1,
0, 1, 1, 1, 0, 0, 0, 0,
1, 1, 0, 0, 1, 0, 1, 0
]
})
Running it
from edgepoint import search
train_results_df, test_results_df, top_combos = search(
df,
outcome_col="hit",
mode="quick",
min_coverage=20,
top_combos_n=2,
show_progress=True,
)
Running that prints a five-stage progress log (suppress it with
show_progress=False):
EDGEPOINT.
--------------------------------
-> 🟢 QUICK < 3 mins (active)
🟡 STANDARD > 3 mins
🔴 DEEP > 10 mins
start : 2026-07-16 22:28:05.
[1/5] Train / Test Split
--------------------------------
train n=140. test n=60. total n=200.
done in 0mins 0 secs | elapsed 0mins 1 secs | 22:28:06.
[2/5] Single-Column Search (train, n=140)
--------------------------------
column edge_point hit_rate miss_rate gap n coverage
0 score 65.67 92.11 7.89 84.21 38 27.14
1 age 18.00 43.57 56.43 -12.86 140 100.00
done in 0mins 0 secs | elapsed 0mins 1 secs | 22:28:06.
[3/5] Single-Column Evaluation (test, n=60)
--------------------------------
column edge_point hit_rate miss_rate gap n coverage
0 score 65.67 80.00 20.00 60.00 20 33.33
1 age 18.00 41.67 58.33 -16.67 60 100.00
done in 0mins 0 secs | elapsed 0mins 2 secs | 22:28:07.
[4/5] Combo Generation & Evaluation (train, mode='quick') ..This may take a while.
--------------------------------
2 combo(s) qualified (train).
combo_num combo hit_rate miss_rate gap n coverage
0 1 score 92.11 7.89 84.21 38 27.14
1 3 score, age 92.11 7.89 84.21 38 27.14
done in 0mins 0 secs | elapsed 0mins 2 secs | 22:28:08.
[5/5] Combo Validation (test, mode='quick')
--------------------------------
2 combo(s) qualified (test).
combo_num combo hit_rate coverage n train_hit_rate train_coverage score
0 1 score 80.00 33.33 20 92.11 27.14 1.0
1 3 score, age 80.00 33.33 20 92.11 27.14 1.0
done in 0mins 0 secs | elapsed 0mins 3 secs | 22:28:08.
TOP 2 COMBOS (test)
--------------------------------
: combo #1 combo #3
score : 65.67 65.67
age : - 18.0
......................................................
hit_rate : 80.0 80.0
coverage : 33.33 33.33
train_hit_rate : 92.11 92.11
train_coverage : 27.14 27.14
combo_sample_size : 20 20
test_sample_size : 60 60
COMPLETE - total duration 0mins 4 secs (end 22:28:09).
And the three values search() returns:
>>> train_results_df
column edge_point hit_rate miss_rate gap n coverage
0 score 65.67 92.11 7.89 84.21 38 27.14
1 age 18.00 43.57 56.43 -12.86 140 100.00
>>> test_results_df
column edge_point hit_rate miss_rate gap n coverage
0 score 65.67 80.00 20.00 60.00 20 33.33
1 age 18.00 41.67 58.33 -16.67 60 100.00
>>> top_combos
[{'combo_num': 1, 'edges': {'score': 65.67}, 'hit_rate': 80.0, 'coverage': 33.33,
'train_hit_rate': 92.11, 'train_coverage': 27.14, 'combo_sample_size': 20, 'test_sample_size': 60},
{'combo_num': 3, 'edges': {'score': 65.67, 'age': 18.0}, 'hit_rate': 80.0, 'coverage': 33.33,
'train_hit_rate': 92.11, 'train_coverage': 27.14, 'combo_sample_size': 20, 'test_sample_size': 60}]
Reading this: score's threshold of 65.67 looked strong on train (92.11%
hit rate over 27.14% of rows) and, replayed unchanged against test, held up
at 80.00% over 33.33% of rows, a real but expected drop, not a collapse.
Adding age to the combo changed nothing here (its own threshold barely
qualified on train), which is itself a useful, honest result.
How the pipeline works
search() runs five stages every time:
-
Train / test split -
sample_methodcontrols how:"asc"(default) splits chronologically, train = the oldesttrain_split% of rows, test = the newer remainder, a walk-forward split where train never sees the future relative to test."desc"is the mirror image: train = the most recent chunk, test = the older chunk before it."random"shuffles rows first, which only makes sense when rows are independent observations with no meaningful time ordering. -
Single-column search (train) - for every numeric column, candidate edges are laid out along the column's own percentile distribution (not equal-width), so dense clusters of values get finer-grained candidate edges and sparse tails get wider ones. At each candidate edge,
edgepointcomputes the hit rate above the edge, the hit rate below it, the gap between them, and the coverage (% of rows at or above the edge). Edges belowmin_coverageare discarded. Among what's left, each edge is scored as a weighted blend of its gap and its coverage, and the single best-scoring edge per column is kept. One row per qualifying column is returned astrain_results_df; columns with too few rows, a constant range, or no edge that clearsmin_coverageare simply absent, notNone, just not present. -
Single-column evaluation (test) - every winning threshold from step 2 is replayed against test data exactly as-is, no re-optimization, no re-scoring. This is a pure readout of how each train-side pick performs on unseen data, returned as
test_results_dfwith the same shape astrain_results_df. -
Combo generation & evaluation (train) - using only the columns that qualified in step 2,
edgepointbuilds AND-combinations of their thresholds (score >= x AND age >= y, etc.), evaluates each combination on train the same way (hit rate, gap, coverage), and keeps only combinations that clearmin_coverageand whose hit rate is within a fixed tolerance of the best combination found.modecontrols how large a combination can get, see below. -
Combo validation (test) - every surviving combination's exact thresholds are replayed against test, with a fresh coverage check run on test itself. A combination that comfortably cleared
min_coverageon train can still get dropped here if it doesn't hold up on test. What's left is re-scored and sorted, and the toptop_combos_nare returned.
The whole point of steps 3 and 5 is that nothing is ever re-tuned on test, you're seeing exactly how train's picks generalize, not a second round of fitting.
Modes
mode controls how large a column-combination is allowed to get in step 4:
| Mode | Combo size | Typical runtime |
|---|---|---|
"quick" |
up to 2 columns per combo | < 3 mins |
"standard" |
up to 3 columns per combo (default) | > 3 mins |
"deep" |
auto-fit, the largest combo size that keeps total combinations under a fixed safety ceiling, given however many columns qualified in step 2 | > 10 mins |
"quick" and "standard" are refused up front (before any real work
starts) if the number of qualifying columns would blow past that same
safety ceiling at the requested combo size, better a clear error than a
runaway search.
Parameters
| Parameter | Default | Description |
|---|---|---|
df |
required | Input DataFrame |
outcome_col |
required | Binary outcome column (0/1, True/False, or a mix) |
columns |
None |
Columns to consider. Defaults to every numeric column except outcome_col |
min_coverage |
20 |
Minimum % of rows that must clear a threshold for it to qualify, applied independently on train and test, for both single columns and combos |
mode |
"quick" |
"quick", "standard", or "deep", controls combo size, see above |
sample_method |
"asc" |
"asc", "desc", or "random", how the train/test split is made |
train_split |
70 |
% of rows assigned to train |
top_combos_n |
1 |
How many top combos to return (capped at 3). 1 returns a single dict; more returns a list |
show_progress |
True |
Print the full five-stage banner/progress log |
min_rows |
15 |
Minimum rows a column needs (after dropping missing values) before it's searched at all |
range_bins |
15 |
Number of percentile-based candidate edges evaluated per column |
debug |
False |
Print every qualifying row at each stage instead of a top-5 preview |
Two things intentionally aren't exposed as parameters: the gap-vs-coverage
score weighting and the small-sample shrinkage discount (below) are held
fixed internally so that single-column search, combo search, and test-side
combo scoring all rank on the exact same tradeoff. If you need to tune
those directly, edgepoint.core.find() and edgepoint.train_combo.recommend()
are the lower-level functions search() builds on, see their own
docstrings for the full parameter reference.
Returns
search() returns a 3-tuple: (train_results_df, test_results_df, top_combos).
train_results_df - one row per qualifying column from step 2:
column, edge_point, hit_rate, miss_rate, gap, n, coverage,
sorted best-scoring column first.
test_results_df - the same columns, one row per column that appeared
in train_results_df, built by replaying each train-side edge against
test. hit_rate/miss_rate/gap/coverage read "n/a" and n is 0
if nothing on the test side clears that edge.
top_combos - a single dict if top_combos_n=1 (the default), or a
list of up to top_combos_n dicts otherwise. Each dict:
{
"combo_num": 1,
"edges": {"score": 65.67}, # {column: edge_point} used by this combo
"hit_rate": 80.0, # test-side
"coverage": 33.33, # test-side
"train_hit_rate": 92.11, # carried over from train, unchanged
"train_coverage": 27.14, # carried over from train, unchanged
"combo_sample_size": 20, # rows clearing the combo on test
"test_sample_size": 60, # total test rows
}
If nothing qualified on test at all, you get a single dict with
combo_num: None and empty edges, regardless of top_combos_n.
Using the result correctly
The thresholds search() returns are not intended to become permanent.
They're the best candidates given the data available right now, checked
once against a held-out slice of that same data. As more observations
arrive, the best threshold, and how well it holds on new data, can shift,
strengthen, weaken, or disappear entirely.
search() answers exactly one question:
"Given the data I have right now, split honestly into train and test, where's the best candidate threshold, and how did it actually hold up?"
It deliberately does not answer:
"How long should I trust this threshold going forward?"
That responsibility belongs to whatever system calls this repeatedly over
time: recompute periodically, check more often while data is thin, widen
tolerance as evidence accumulates, and demote or remove thresholds that
stop performing. Think of search() as proposing and stress-testing a
threshold once, not certifying one forever.
Supported outcome values
The outcome column may contain:
0/1True/False0.0/1.0- any mixture of the above
Any other value (strings, 2, -1, etc.) raises a ValueError before any
column is searched, a full stop, not a per-column skip.
Design notes
Why percentile-based candidate edges instead of equal-width bins? Candidate edges are placed along the column's own quantiles, so dense clusters of values get finer-grained cuts and sparse tails get wider ones, as a natural consequence of where the data actually sits. Duplicate edge values collapse automatically, which matters for count-like or heavily rounded columns.
Why a weighted score instead of hard gap/rate thresholds?
Rather than requiring a threshold to clear a fixed minimum gap and a fixed
minimum success rate, edgepoint scores every coverage-qualified candidate
as a blend of its gap and its coverage, and picks the best-scoring one.
Coverage is still a hard gate (min_coverage), nothing under that bar
competes at all, but among what's left, the tradeoff between "how big is
the edge" and "how much of the population does it cover" is continuous,
not a pass/fail cliff.
Why discount small-sample gaps before scoring? A dramatic-looking gap sitting on a handful of rows is less trustworthy than a smaller gap backed by hundreds of rows. Before the gap enters the scoring step, it's discounted toward zero in proportion to how few rows support it (more rows → smaller discount), so a flashy small-sample edge can no longer automatically outscore a well-supported one. The raw, undiscounted gap and hit rate are always what's reported back, the discount only affects which edge wins, not what's displayed.
Why a train/test split at all, if it's still "one shot"? Because a threshold search run only on the full dataset can't tell you whether the pattern it found generalizes or was a fluke of that particular data. Splitting into train and test, and re-evaluating train's exact picks, with no retuning, against test, gives an honest read on the second question without pretending it answers "how long will this hold in the future."
One-directional search. edgepoint only ever asks "is there a point
above which the rate is better than below it?" It does not
independently score the below-side against its own bar; "below" is only
known to be worse than "above," a comparative statement, not two
independently verified zones.
Who this is for
Not primarily ML researchers with an established modeling toolbox already in hand, they don't need this to solve the raw math. This is for people closer to how it was actually built: solo builders, indie quants, and analysts who would rather read and trust code they can fully audit than hand a threshold decision to a library's internals, and who want a basic train/test sanity check built in rather than bolted on separately.
FAQ
Does this replace machine learning?
No. Machine learning predicts outcomes; edgepoint proposes interpretable
operational thresholds and checks them against held-out data.
Is this statistically optimal? Not necessarily, and not the goal. It prioritizes interpretability and honest generalization checking over optimizing a pure statistical objective.
Why might a column or combo be missing from the results entirely?
Rather than returning None for every unqualified column, edgepoint
simply omits it, too few rows, a constant range, or nothing clears
min_coverage. Absence is itself informative: no candidate was good
enough.
Should I hard-code a threshold from search()?
No. Treat it as a snapshot checked against one held-out slice, and
recompute as new observations arrive. See "Using the result correctly"
above.
Can I skip the train/test split and combo search, and just get
single-column thresholds on the full dataset?
Yes, edgepoint.core.find() is the lower-level function search()
calls internally for single-column search; it can be called directly on
its own DataFrame. See its own docstring for parameters.
Naming note
This package went through a few naming iterations before landing here,
early candidates included optimal_start, sweet_spot, sweetspot, and
maxspot, each rejected either for not capturing the actual idea (a
zone/point where rate improves without sacrificing population) or for
colliding with an existing PyPI package. edgepoint was checked and
confirmed clear, and both the import and the PyPI distribution name now
match, so there's no split between what you pip install and what you
import.
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file edgepoint-2.0.1.tar.gz.
File metadata
- Download URL: edgepoint-2.0.1.tar.gz
- Upload date:
- Size: 43.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.9
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
36d42fb91a626e503ee51ae0ca7100caf0ad21e89bb39d678704c28ceff2e19f
|
|
| MD5 |
34a75be1dd96775ccc97573b9208179c
|
|
| BLAKE2b-256 |
db5ef00924980d566cb2334e701be1afbd7704293acd1cb6eb8d21906d1b0498
|
File details
Details for the file edgepoint-2.0.1-py3-none-any.whl.
File metadata
- Download URL: edgepoint-2.0.1-py3-none-any.whl
- Upload date:
- Size: 38.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.9
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0dac53171b02798ef016ea9582a00750e8ecc373fcffd774c0ff81548ed09f3b
|
|
| MD5 |
81d6bdc8e6ed6f738fa3e1a461cf02f6
|
|
| BLAKE2b-256 |
e2679eed4efc5bb6d74f9a2783998675b9b2f84a20f5468653a5daba75449b73
|