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.
Built for data analysts who aren't deep into data science and don't want to be, who just want a quick, trustworthy answer to a data question without writing a model, tuning hyperparameters, or learning a new toolbox to get there.
A note on terms: throughout this README, "threshold", "edge point", "metric", "variable", and "column" are all used interchangeably. They all just mean a numeric column in your DataFrame.
edgepoint splits your data into train and test, searches train for the
best single-column threshold(s) and the best AND-combination(s) of
those thresholds, then replays every one of those exact picks against
test with no re-optimization. What you get back isn't just "the best
split we could find", it's that, plus an honest readout of how it held
up on data it never touched.
from edgepoint import search
edgepoints_df, top_combos = search(df, outcome_col="hit")
Correlation is not causation
search() finds correlations, not causes. A great-looking threshold
doesn't mean crossing it causes the outcome to improve, it may be
riding along with something else entirely, or just noise. This risk is
worse on small data: an 80% hit rate on 5 rows is far more likely to be
a fluke than the same 80% on 500 rows.
So supervise what you pass in. Only include columns with a plausible
reason to relate to the outcome, treat thin n results as leads to
investigate rather than decisions to act on, and re-run as more data
arrives rather than locking in a threshold found on a thin slice.
Where this applies
Any continuous metric (or several) plus a 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
outcome_col: telling edgepoint what "good" means
outcome_col is your 0/1 (or True/False) judgment of what counts
as a win on each row. edgepoint has no idea what a good sale, patient
outcome, or bet looks like in your business. Everything else it computes
(hit_rate, gap, coverage, score) is just counting, over and over,
against the column you point it at.
- It must already exist. Derive it (e.g. "closed within 7 days") before
calling
search(). - One outcome per run. Two definitions of success means two calls.
- Rare outcomes (almost all
0s or almost all1s) leave little contrast to find an edge across, and columns are likely to get skipped for failingmin_coverage.
Quick start
import pandas as pd
from edgepoint import search
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
]
})
edgepoints_df, top_combos = search(
df,
outcome_col="hit",
mode="quick",
min_coverage=10,
show_progress=True,
top_combo_n=3,
)
With show_progress=True, search() prints a 6 stage log (data
sanity/splitting, train search, test validation, merged edges, merged
combos, top combo summary) as it runs. With show_progress=False it runs
silently and just returns the two values below.
Auto split & auto fold
There's no train_split or k parameter to set. Both are worked out
for you from the data itself:
- Split:
search()takesdfexactly as ordered and treats the oldest rows as train, the newest rows as test (a walk forward split; train never sees the future relative to test). The train/test ratio is picked automatically from row count. If your rows aren't already in chronological order, or you don't want a chronological split at all, shuffledfyourself before passing it in.search()never reorders or randomizes rows on its own. - Folds: within train and again within test, the number of k-folds
used for validation is auto-sized off row count and
min_coverage, so each fold still has enough rows to mean something. Folds themselves are randomly stratified, rows are shuffled and split so each fold keeps roughly the same positive rate as the whole set, rather than plain random slicing that could hand one fold almost all0s or almost all1s.
How the pipeline works
- Data sanity / splitting:
dfis cleaned first. Invalid outcome rows dropped,True/Falsenormalized to1/0, non-numeric, constant, or near-unique predictor columns dropped, remainingNaN/inffilled. The cleaned data is then split into train/test automatically, chronologically, based on row count, with no split ratio to configure. - Train section: every numeric column is searched for its best
single-column threshold (percentile-based candidate edges, scored on
a shrinkage-discounted gap/coverage blend), then qualifying columns
are combined into AND-combinations (size capped by
mode) and k-fold validated on train itself. - Test section: every train-side edge and surviving combo is replayed against test, unchanged, with its own k-fold validation.
- Merge edges: train and test side stats for each column are
merged side by side (
_tr/_tesuffixes) intoedgepoints_df, ranked by a blendedfinal_score. - Merge combos: same idea, but only combos that qualified on both train and test survive.
- Top combos: the best
top_combo_ncombos are printed and returned astop_combos.
A combo (or column) only counts as a real edge if its hit rate clears the global baseline, the dataset's own overall hit rate ("doing nothing"), computed once on the full df and reused, unchanged, at every stage. Nothing is ever re-tuned on test.
Modes
mode controls how large a column combination can get during combo
search:
| Mode | Combo size |
|---|---|
"quick" |
up to 2 columns per combo |
"standard" |
up to 3 columns per combo |
"deep" |
auto-fit, the largest size that keeps total combos under a fixed safety ceiling |
"quick"/"standard" are refused up front if they'd blow past that
ceiling given how many columns qualified.
Parameters
| Parameter | Default | Description |
|---|---|---|
df |
required | Input DataFrame |
outcome_col |
"hit" |
Binary outcome column (0/1, True/False, or a mix) |
mode |
"quick" |
"quick", "standard", or "deep", controls combo size, see above |
min_coverage |
10 |
Minimum % of rows a threshold/combo must cover to qualify (train and test) |
show_progress |
True |
Print the full stage-by-stage log |
top_combo_n |
3 |
How many top combos to return. 1 returns a single dict; more returns a list |
Returns
search() returns a 2-tuple: (edgepoints_df, top_combos).
edgepoints_df: one row per column that appeared on either side,
with train (_tr) and test (_te) versions of hit_rate, miss_rate,
gap, n, coverage, plus a blended final_score, sorted best first.
top_combos: a single dict if top_combo_n=1, else a list of up to
top_combo_n dicts, each shaped like:
{
"combo_num": 3,
"combo": "score, age",
"edges": {"score": 49.46, "age": 39.5},
"hit_rate_tr": 80.0, "hit_rate_te": 60.0,
"coverage_tr": 41.67, "coverage_te": 31.25,
"n_tr": 10, "n_te": 5,
"score_tr": 0.62, "score_te": 0.41,
"final_score": 21.49,
}
If nothing qualified, you get a single dict with combo_num: None and
empty edges.
What a combo actually tells you
Think of top_combos as the answer to "which traits, combined,
actually separate my winners from everyone else, and by how much?"
A single-column result might tell you customers 34+ buy at 58% vs 22%
for everyone else, useful, but crude, since plenty of 22-year-olds buy
too. A combo sharpens that: age >= 34 AND minutes_on_site >= 6.5 might
come back at a 71% hit rate on train and 68% on test, covering 18% of
visitors against a 24% baseline. That's a different, more useful claim.
It's not age alone, it's that age stacked with engagement time is
where the real signal was hiding, and it held up on data the search
never saw. top_combos typically hands you a few of these to compare,
so you can weigh, say, "age + time on site" against "past purchases +
email opens," and pick whichever combo is both strong and backed by
enough rows (n_tr/n_te) to actually trust.
Using the result correctly
The thresholds search() returns aren't meant 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. Re-run search() as new rows
arrive. Each run rechecks the spike against a fresh split and tells you
plainly if it moved, held, or disappeared. Treat it as proposing and
stress-testing a threshold once, not certifying one forever.
Supported outcome values
outcome_col may contain 0/1, True/False, 0.0/1.0, or any mix
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
- Percentile-based candidate edges: candidate edges follow the column's own quantiles, so dense clusters get finer cuts and sparse tails get wider ones.
- Weighted score, not hard cutoffs:
min_coverageis a hard gate; among what clears it, gap and coverage are blended into one score rather than requiring both to clear fixed minimums independently. - Shrinkage-discounted gaps: a dramatic gap on a handful of rows is discounted toward zero before scoring, so it can't outrank a smaller, better-supported gap. Raw hit rate/gap are always what's reported.
- Sign-flip penalty: if a combo's gap direction flips between train
and test, that's a concrete overfitting signal, so
final_scoreis halved for that row. - One-directional search:
edgepointonly asks "is there a point above which the rate is better?" It doesn't independently verify the below side against its own bar.
License
MIT
Project details
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-3.0.1.tar.gz.
File metadata
- Download URL: edgepoint-3.0.1.tar.gz
- Upload date:
- Size: 62.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.9
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
69fd1e7a1da406a6afa5b7c4199b9de029a8a3298ca06a139f0a14fabab6a939
|
|
| MD5 |
6907d956d54313bfe79aeee4e463cacc
|
|
| BLAKE2b-256 |
2ac91d0e3b4a9b9c13d06bce32621558e9aba211b2aae706c679416e50fa5aba
|
File details
Details for the file edgepoint-3.0.1-py3-none-any.whl.
File metadata
- Download URL: edgepoint-3.0.1-py3-none-any.whl
- Upload date:
- Size: 67.9 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 |
8b30d27b4c7f15fa75a84d33af460ec01d17a135450fda85ad32edc208eb38a1
|
|
| MD5 |
557ed0c8bde351569190a8e2c5765294
|
|
| BLAKE2b-256 |
6a5dcd98fdec8848385be7518ebad66f8b7e06cc936a3722cb5fa579a1a012a6
|