Skip to main content

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:

  1. Train / test split - sample_method controls how: "asc" (default) splits chronologically, train = the oldest train_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.

  2. 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, edgepoint computes 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 below min_coverage are 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 as train_results_df; columns with too few rows, a constant range, or no edge that clears min_coverage are simply absent, not None, just not present.

  3. 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_df with the same shape as train_results_df.

  4. Combo generation & evaluation (train) - using only the columns that qualified in step 2, edgepoint builds 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 clear min_coverage and whose hit rate is within a fixed tolerance of the best combination found. mode controls how large a combination can get, see below.

  5. 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_coverage on train can still get dropped here if it doesn't hold up on test. What's left is re-scored and sorted, and the top top_combos_n are 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 / 1
  • True / False
  • 0.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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

edgepoint-2.0.1.tar.gz (43.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

edgepoint-2.0.1-py3-none-any.whl (38.0 kB view details)

Uploaded Python 3

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

Hashes for edgepoint-2.0.1.tar.gz
Algorithm Hash digest
SHA256 36d42fb91a626e503ee51ae0ca7100caf0ad21e89bb39d678704c28ceff2e19f
MD5 34a75be1dd96775ccc97573b9208179c
BLAKE2b-256 db5ef00924980d566cb2334e701be1afbd7704293acd1cb6eb8d21906d1b0498

See more details on using hashes here.

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

Hashes for edgepoint-2.0.1-py3-none-any.whl
Algorithm Hash digest
SHA256 0dac53171b02798ef016ea9582a00750e8ecc373fcffd774c0ff81548ed09f3b
MD5 81d6bdc8e6ed6f738fa3e1a461cf02f6
BLAKE2b-256 e2679eed4efc5bb6d74f9a2783998675b9b2f84a20f5468653a5daba75449b73

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page