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.

edgepoint is a lightweight Python library for discovering interpretable thresholds in numeric data. Given a numeric feature and a binary outcome, it answers one practical question:

"Where does this variable start becoming good enough?"

Instead of fitting a predictive model, edgepoint searches for the point where performance becomes good without giving up more of the population than necessary. It favors thresholds that stay both effective and widely applicable, rather than isolating a tiny group with exceptional results. If no convincing threshold exists, it simply returns None.

No charts. No notebook. Hand it a DataFrame, get a number back, built to be called by code, not stared at by a human.

from edgepoint import find

profile = find(
    df,
    outcome_col="hit",       # any binary (0/1 or True/False) column
    compute_fallback=True,   # mandatory: fall back to best-available split, or report None
)
# {"score": 66.75, "age": None, ...}

Where this applies

Any continuous metric + 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 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:

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)

edgepoint searches each numeric column for the point above which the outcome rate improves while still retaining a meaningful share of the population.


Install

pip install edgepoint
from edgepoint import find

Quick start

import pandas as pd
from edgepoint import find

df = pd.DataFrame({
    "score": [
         5,  8, 11, 14, 17, 20, 23, 26,
        29, 32, 35, 38, 41, 44, 47, 50,
        53, 56, 59, 62, 65, 68, 71, 74,
        77, 80, 83, 86
    ],
    "hit": [
        0, 0, 1, 0, 1, 1, 0, 1,
        1, 0, 1, 1, 0, 1, 1, 1,
        1, 0, 1, 1, 1, 1, 1, 1,
        0, 1, 1, 1
    ]
})

result = find(
    df,
    outcome_col="hit",
    compute_fallback=True,
    show_progress=True,
)

print(result)
============================================================
  edgepoint.find | outcome_col='hit'  columns=['score']
  range_bins=15  gap_threshold=10%  target_pct=60%  min_coverage_pct=30%  fallback_tolerance_pct=3%  primary_tiebreak='gap'  compute_fallback=True
============================================================

    [score] n=28  range=[5 .. 86]  edges=14
      checking every edge (min_coverage_pct=30% floor):
        edge=10.4   >= 10.4: n=26    rate=76.9%   < 10.4: n=2     rate=0.0%   gap=+76.9%  coverage=93%
        edge=15.8   >= 15.8: n=24    rate=79.2%   < 15.8: n=4     rate=25.0%   gap=+54.2%  coverage=86%
        edge=21.2   >= 21.2: n=22    rate=77.3%   < 21.2: n=6     rate=50.0%   gap=+27.3%  coverage=79%
        edge=26.6   >= 26.6: n=20    rate=80.0%   < 26.6: n=8     rate=50.0%   gap=+30.0%  coverage=71%
        edge=32     >= 32:   n=19    rate=78.9%   < 32:   n=9     rate=55.6%   gap=+23.4%  coverage=68%
        edge=37.4   >= 37.4: n=17    rate=82.4%   < 37.4: n=11    rate=54.5%   gap=+27.8%  coverage=61%
        edge=42.8   >= 42.8: n=15    rate=86.7%   < 42.8: n=13    rate=53.8%   gap=+32.8%  coverage=54%
        edge=48.2   >= 48.2: n=13    rate=84.6%   < 48.2: n=15    rate=60.0%   gap=+24.6%  coverage=46%
        edge=53.6   >= 53.6: n=11    rate=81.8%   < 53.6: n=17    rate=64.7%   gap=+17.1%  coverage=39%
        edge=59     >= 59:   n=10    rate=90.0%   < 59:   n=18    rate=61.1%   gap=+28.9%  coverage=36%
        edge=64.4   (coverage 29% < min_coverage_pct=30%) -> skipped
        edge=69.8   (coverage 21% < min_coverage_pct=30%) -> skipped
        edge=75.2   (coverage 14% < min_coverage_pct=30%) -> skipped
        edge=80.6   (coverage 7% < min_coverage_pct=30%) -> skipped
      -> PRIMARY: split at 10.4, gap=76.9%, above_rate=76.9%, coverage=93% (>= gap_threshold=10% AND target_pct=60%; tiebreak=gap)

Output:

{
    "score": 10.4
}

Meaning: scores of 10.4 and above produce a noticeably higher hit rate (76.9%) than scores below 10.4 (0.0%), while still covering 93% of the population, well clear of every quality bar.


Using the result correctly

The value returned by find() is not intended to become a permanent threshold. It is simply the best candidate based on the data available at the time the function was called.

As more observations arrive, the best threshold may shift, strengthen, weaken, or disappear entirely. A threshold that looked convincing today may no longer be appropriate once more data has accumulated. This is intentional.

edgepoint answers exactly one question:

"Given the data I have right now, where is the best candidate threshold?"

It deliberately does not answer:

"How long should I trust this threshold?"

That responsibility belongs to the system using this package. A typical production workflow:

  • recompute thresholds periodically, not once
  • check more frequently while data is limited
  • let tolerance relax as more evidence accumulates
  • monitor performance over time
  • automatically demote or remove thresholds that stop performing

Think of find() as proposing a threshold, not certifying one forever.


Multiple columns

By default, every numeric column except the outcome column is scanned.

result = find(
    df,
    outcome_col="converted",
    compute_fallback=False,
)
{
    "age": None,
    "income": 42000.0,
    "engagement_score": 67.14,
    "rating": 4.10
}

Each column is evaluated independently.


Scanning selected columns

result = find(
    df,
    outcome_col="converted",
    columns=["score", "income", "age"],
    compute_fallback=True,
)

Pass the full DataFrame and use columns as a whitelist, no need to pre-slice the data yourself.


Custom parameters

result = find(
    df,
    outcome_col="converted",
    compute_fallback=True,
    gap_threshold=15,
    target_pct=70,
    min_coverage_pct=35,
    range_bins=20,
    primary_tiebreak="gap",
    show_progress=True,
)

Parameters

Parameter Required Description
outcome_col Yes Binary outcome column
compute_fallback Yes Whether weaker fallback thresholds are allowed
columns No Columns to scan. Defaults to every numeric column
gap_threshold No Minimum improvement required, in percentage points
target_pct No Minimum success rate required, in %
min_coverage_pct No Minimum percentage of rows above the threshold
range_bins No Number of equal-width candidate edges
fallback_tolerance_pct No Near-tie tolerance, in percentage points
primary_tiebreak No "gap" or "rate"
min_rows No Minimum rows required before scanning a column
show_progress No Print every candidate edge while scanning

See edgepoint/core.py for the complete parameter reference and defaults.


Returns

find() returns a dictionary whose keys are column names and whose values are the detected thresholds:

{
    "score": 63.8,
    "income": 42000,
    "age": None
}

A value of None means no threshold satisfied the requested criteria. This is a legitimate, reportable result, not a gap to be patched over.


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 scanned, a full stop, not a per-column skip. One bad value hiding anywhere in the outcome column, even among hundreds of otherwise clean rows, halts the entire call.


Inspecting a decision

find(
    df,
    outcome_col="hit",
    compute_fallback=True,
    show_progress=True,
)

show_progress=True prints every candidate threshold as it is evaluated, including coverage, outcome rates above and below the threshold, improvement (gap), rejected candidates and why, and whether the primary or fallback tier produced the final result. Useful for tuning parameters or validating thresholds on new datasets.


How it works

For every numeric column:

  1. Determine the column's minimum and maximum values.
  2. Divide that range into equal-width candidate edges.
  3. Evaluate every candidate threshold: outcome rate above, outcome rate below, improvement (gap), and coverage.
  4. Reject candidates that fail the coverage floor.
  5. Among the rest, select the best remaining threshold using a two-tier, coverage-preferring tiebreak.
  6. Return None if no convincing threshold exists.

The algorithm is deterministic: the same data with the same parameters always produces the same result.


Design journal

This section exists because most packages hide the why and only ship the what. Every decision below was actually argued out before it was built, including the ones that almost went a different way.

Why not just use isotonic regression or a decision-tree split? Both exist and both are more "textbook optimal" in a narrow sense. Decision trees optimize prediction; they don't automatically enforce minimum coverage, minimum success rate, minimum improvement, or stable tiebreaking among near-ties, those are left to whoever builds the model. Isotonic regression estimates a monotonic probability curve, but doesn't directly answer "where should the operational threshold sit," you still have to decide where "good enough" begins. Neither method has a coverage guard by default, so both will happily hand you a "great" split sitting on 8 rows. If you're fluent enough in ML to reach for optbinning or sklearn, you don't strictly need this package to solve the raw math. What you get here instead is the guard logic and the fallback decision made explicit, visible, and enforced, not bolted on after the fact.

Why equal-width bins instead of quantile bins? Quantile (qcut) edges are population-based, they guarantee similar row count per step, not similar value range. On a column with a lot of duplicate or clustered values, quantile edges collapse toward the crowded region, and a real effect sitting right at that cluster gets buried inside one wide edge alongside completely different values. Equal-width edges are spaced evenly across the actual range, so a cluster still gets a nearby edge to split on.

Why coverage instead of range-position as the guard? The first version of this logic used a range-position cutoff (a max percentile of the range) to avoid thin slivers. That turned out to be only a proxy for the real thing being protected against, on a skewed column, range-position and actual row coverage decouple. An edge sitting at 67% of the way across the range can still cover only 4% of rows, while an edge at 20% across can cover 91%. Checking coverage directly, against real row counts, is the more honest guard.

Why is compute_fallback mandatory, with no default? Because whether a column should ever settle for a "best available, but weaker" split instead of reporting None is a modeling decision, not something that should silently default one way forever. Omitting it raises a ValueError on purpose. You have to consciously choose True or False every time.

Should this punish a range with many rows and a bad rate, and not over-trust a range with few rows and a good rate, automatically, like a promotion system? This came up directly: the instinct was "a new signal showing promise should get gradually promoted, not fully trusted on day one, the way a new hire earns trust over time." The honest answer: yes, that's a real mechanism (formally, a Wilson-lower-bound-style confidence penalty that scales with row count), but it does not belong inside this function. This function's job is to propose a candidate spot on the data it's given right now, a single, static, one-shot check. The gradual-trust mechanism belongs in whatever live system calls this repeatedly over time: check sooner when data is thin, widen tolerance as more data accumulates, and let a live feedback loop auto-correct a promoted spot that stops performing. That reasons about performance as data keeps arriving, not just at one static moment, which is more information than any confidence interval computed once could ever give you. So: the coverage floor stays a hard gate here, and the graduated-trust logic stays out of this package, on purpose, it's where your own orchestration layer earns its keep, and it's arguably the more proprietary part worth keeping close anyway.

Does it only find a "good" zone, or does it independently verify the "bad" zone too? One-directional, by design. This function 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, it only knows that "below" is worse than "above," which is a comparative statement, not two independently-verified zones. In practice those usually amount to the same thing, but it's worth being precise about what's actually being claimed.

Is this reinventing the wheel? Partly, and that's fine to say plainly. The underlying math (walk a range, find a threshold, compare two groups) isn't novel. What isn't already sitting in a well-known library is the specific combination of an explicit coverage floor, a forced fallback decision, a tolerance-based tiebreak that consistently prefers coverage over noise, and full per-edge transparency via show_progress, every rejected edge and why it was rejected, not just the final answer. That combination is what makes it something you can actually audit, which matters more than raw statistical optimality in a system where a human has to trust and act on the output.


Design decisions, summarized

  • Equal-width candidate edges avoid dense regions dominating the search.
  • Coverage guard measured directly with row counts, not percentile position, so tiny high-performing groups are never over-trusted.
  • Mandatory fallback decision, compute_fallback has no default, because whether weaker thresholds are acceptable is a modeling call you must make explicitly every time.
  • Honest None, when no convincing threshold exists, that's reported as a legitimate outcome, not forced into a weak recommendation.
  • Coverage-first tiebreaking, among near-identical candidates, the broader-coverage threshold wins.
  • One-directional search, asks only "above what value does the outcome become better," and does not independently validate the below-threshold region as its own zone.

Who this is for

Not primarily ML researchers with optbinning already in their toolbox, 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 100 lines they can fully audit than hand a threshold decision to a library's internals. If a chart in a notebook is your normal workflow, this is the same idea, minus the human staring at the chart, a df in, a structured answer out, ready to be called from inside a live system, not just eyeballed once and forgotten.


FAQ

Does this replace machine learning? No. Machine learning predicts outcomes; edgepoint proposes interpretable operational thresholds. Different problem.

Is this statistically optimal? Not necessarily, and not the goal. It intentionally prioritizes interpretability and operational usefulness over optimizing a pure statistical objective.

Why can the function return None? Because not every variable contains a convincing threshold. Returning None is more honest than forcing a weak recommendation.

Should I hard-code the threshold? No. Treat it as a snapshot of your current data and recompute as new observations arrive. See "Using the result correctly" above.


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-1.0.0.tar.gz (25.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-1.0.0-py3-none-any.whl (18.2 kB view details)

Uploaded Python 3

File details

Details for the file edgepoint-1.0.0.tar.gz.

File metadata

  • Download URL: edgepoint-1.0.0.tar.gz
  • Upload date:
  • Size: 25.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.1

File hashes

Hashes for edgepoint-1.0.0.tar.gz
Algorithm Hash digest
SHA256 2b72586c928d9a818644dcaaf743ba1257215ea22c051db80f9dcda236cca1f8
MD5 4cb7761da0de8f2cb25d2b635b0ab18b
BLAKE2b-256 7c6c88d2cd939434ddf410127fda11229caf43d806fccd8f3932ac7a58b1a7da

See more details on using hashes here.

File details

Details for the file edgepoint-1.0.0-py3-none-any.whl.

File metadata

  • Download URL: edgepoint-1.0.0-py3-none-any.whl
  • Upload date:
  • Size: 18.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.1

File hashes

Hashes for edgepoint-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 4565b70c8e5601c22c83ede72383bb2c32b40f3f2884e928788e16a25f1b20d2
MD5 8aa64d3898afb850e66fbc4c7ea3aa00
BLAKE2b-256 5f2680444ab3a708637283f9104a8da193ead479e3bee6d00f44b6816ef9cbcd

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