Metadata-Version: 2.4
Name: edgepoint
Version: 2.0.3
Summary: Find the point in a numeric column above which a binary outcome becomes meaningfully and reliably better.
Author: Henry
License: MIT
Keywords: threshold,decision-threshold,binary-classification,feature-engineering,data-analysis,analytics,statistics,edge,threshold-detection
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Science/Research
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Scientific/Engineering
Classifier: Topic :: Scientific/Engineering :: Information Analysis
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy>=1.21
Requires-Dist: pandas>=1.5
Provides-Extra: test
Requires-Dist: pytest>=7.0; extra == "test"
Dynamic: license-file
Dynamic: requires-python

# 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?"**

`edgepoint` makes a simple, less-savvy-visible property of your data easy
to see: the exact point along a metric where an outcome starts spiking.
`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."

```python
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.

---

## `outcome_col`: telling edgepoint what "good" means

Before `search()` scans a single edge, it needs one thing from you that
matters more than any parameter: `outcome_col`. This is you, the analyst,
labeling every row in your data as a win or a loss, a pass or a fail, a
positive or a negative. `edgepoint` doesn't decide what "success" looks
like in your business, it has no idea what a good sale, a good patient
outcome, or a good bet even is. You decide that, once, by pointing at a
column, and every other number the library produces is downstream of that
one judgment.

Concretely: `outcome_col` must be a column of `1`/`0`, `True`/`False`, or
a mix of both, one value per row, `1` (or `True`) meaning "this row counts
as a success" and `0` (or `False`) meaning "this row doesn't." That's it,
no other values are allowed, `edgepoint` raises a `ValueError` before doing
any work if it finds anything else in that column, rather than silently
guessing which values you meant as the "good" ones.

Once that column exists, everything else in `search()` is really just
counting, over and over, at every candidate edge in every numeric column:
*"of the rows at or above this point, what fraction were a `1`?"* That
fraction is `hit_rate`. The rest, `miss_rate`, `gap`, `coverage`, `score`,
are all built directly on top of that one count. Get `outcome_col` wrong,
too loose, too strict, mislabeled, or measuring the wrong thing entirely,
and every threshold `search()` proposes will be confidently optimized for
the wrong target. Get it right, and the rest of the pipeline (percentile
edges, train/test replay, combo search) is just honest, mechanical counting
against a target you already trust.

A few practical notes on choosing it:

- **It has to already exist as a column.** If "success" in your data is a
  derived condition, e.g. "sale closed within 7 days" or "score improved by
  at least 10 points", compute that as its own `0`/`1` column *before*
  calling `search()`. `edgepoint` labels nothing on its own.
- **One outcome per run.** If you care about two different definitions of
  success (say, "converted" and "converted *and* retained"), that's two
  separate calls to `search()` with two different `outcome_col`s, not one
  call trying to do both.
- **Rare outcomes still need rows on both sides.** If almost every row is a
  `1` (or almost every row is a `0`), there's very little contrast for
  `edgepoint` to find an edge across, and columns are likely to get skipped
  for failing `min_coverage` on whichever side is thin.

---

## 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 two things: one or more **numeric columns** to evaluate, and
the **`outcome_col`** described above, your `0`/`1` (or `True`/`False`)
judgment of what counts as a win on each row. A few example pairings:

| 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

```bash
pip install edgepoint
```

```python
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:

```python
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

```python
from edgepoint import search

train_results_df, test_results_df, top_combos = search(
    df,
    outcome_col="hit",
    train_split=60,      # 24 train / 16 test → both ≥ min_rows
    min_rows=15,
    min_coverage=20,
    mode="quick",
    top_combos_n=2,
    show_progress=False,
)
```

With `show_progress=False` the call above runs silently and just returns
the three values. Turning `show_progress=True` on the exact same call
prints the full five-stage log so you can see how the split, the
single-column search, and the combo search actually got there:

```
EDGEPOINT.
--------------------------------
 -> 🟢  QUICK     <  3 mins  (active)
    🟡  STANDARD  >  3 mins
    🔴  DEEP      > 10 mins

 start : 2026-07-17 07:10:14.

[1/5] Train / Test Split
--------------------------------

Sample Size (n) - Full = 40   . Train = 24   . Test = 16   .

Hit Rate    (%) - Full = 47.50. Train = 50.00. Test = 43.75.

 done in 0mins 0 secs | elapsed 0mins 1 secs | 07:10:15.


[2/5] Single-Column Search (train, n=24)
--------------------------------

  column edge_point hit_rate miss_rate    gap   n coverage
0  score      49.46    75.00     25.00  50.00  16    66.67
1    age      39.50    57.14     42.86  14.29  14    58.33

 done in 0mins 0 secs | elapsed 0mins 1 secs | 07:10:16.


[3/5] Single-Column Evaluation (test, n=16)
--------------------------------

  column edge_point hit_rate miss_rate     gap  n coverage
0  score      49.46    77.78     22.22   55.56  9    56.25
1    age      39.50    42.86     57.14  -14.29  7    43.75

 done in 0mins 0 secs | elapsed 0mins 2 secs | 07:10:16.


[4/5] Combo Generation & Evaluation (train, mode='quick') ..This may take a while.
--------------------------------

 3 combo(s) qualified (train). sample size (n) = 24. hit rate (%) = 50.00.

   combo_num       combo hit_rate miss_rate    gap   n coverage
0          3  score, age    80.00     20.00  60.00  10    41.67
1          1       score    75.00     25.00  50.00  16    66.67
2          2         age    57.14     42.86  14.29  14    58.33

 done in 0mins 0 secs | elapsed 0mins 2 secs | 07:10:17.


[5/5] Combo Validation (test, mode='quick')
--------------------------------

 3 combo(s) qualified (test). sample size (n) = 16. hit rate (%) = 43.75.

   combo_num       combo hit_rate coverage  n train_hit_rate train_coverage  score
0          3  score, age    60.00    31.25  5          80.00          41.67  21.49
1          1       score    77.78    56.25  9          75.00          66.67   1.00
2          2         age    42.86    43.75  7          57.14          58.33 -29.50

 done in 0mins 0 secs | elapsed 0mins 3 secs | 07:10:17.


TOP 2 COMBOS (test)
--------------------------------

Sample Size (n) - Full = 40   . Train = 24   . Test = 16   .
Hit Rate    (%) - Full = 47.50. Train = 50.00. Test = 43.75.

                             : combo #3         combo #1
 Score                       : 49.46            49.46
 Age                         : 39.5             -
 ......................................................
 Hit_rate                    : 60.0             77.78
 Coverage                    : 31.25            56.25
 Train_hit_rate              : 80.0             75.0
 Train_coverage              : 41.67            66.67
 Combo_sample_size           : 5                9
 Test_sample_size            : 16               16
 Baseline_hit_rate (full df) : 47.50            47.50
 Vs_baseline                 : 12.50 +          30.28 +

COMPLETE  -  total duration 0mins 4 secs  (end 07:10:18).
```

And the three values `search()` returns:

```python
>>> train_results_df
  column edge_point hit_rate miss_rate    gap   n coverage
0  score      49.46    75.00     25.00  50.00  16    66.67
1    age      39.50    57.14     42.86  14.29  14    58.33

>>> test_results_df
  column edge_point hit_rate miss_rate     gap  n coverage
0  score      49.46    77.78     22.22   55.56  9    56.25
1    age      39.50    42.86     57.14  -14.29  7    43.75

>>> top_combos
[{'combo_num': 3, 'edges': {'score': 49.46, 'age': 39.5}, 'hit_rate': 60.0, 'coverage': 31.25,
  'train_hit_rate': 80.0, 'train_coverage': 41.67, 'combo_sample_size': 5, 'test_sample_size': 16,
  'baseline_hit_rate': 47.5, 'vs_baseline': 12.5},
 {'combo_num': 1, 'edges': {'score': 49.46}, 'hit_rate': 77.78, 'coverage': 56.25,
  'train_hit_rate': 75.0, 'train_coverage': 66.67, 'combo_sample_size': 9, 'test_sample_size': 16,
  'baseline_hit_rate': 47.5, 'vs_baseline': 30.28}]
```

Reading this: `score`'s threshold of 49.46 looked solid on train (75.00%
hit rate over 66.67% of rows) and, replayed unchanged against test, held up
even better at 77.78% over 56.25% of rows, comfortably ahead of the 47.5%
baseline hit rate. Adding `age` into the combo (`score >= 49.46 AND age >=
39.5`) tightens things further on train (80.00% hit rate) but shrinks
coverage a lot (down to 5 rows on test), and its test hit rate of 60.00%,
while still above baseline, is the less reliable of the two picks precisely
because it's resting on so few rows, exactly the kind of honest trade-off
`search()` is built to surface rather than hide.

---

## 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:

```python
{
    "combo_num": 3,
    "edges": {"score": 49.46, "age": 39.5},  # {column: edge_point} used by this combo
    "hit_rate": 60.0,                        # test-side
    "coverage": 31.25,                       # test-side
    "train_hit_rate": 80.0,                  # carried over from train, unchanged
    "train_coverage": 41.67,                 # carried over from train, unchanged
    "combo_sample_size": 5,                  # rows clearing the combo on test
    "test_sample_size": 16,                  # total test rows
    "baseline_hit_rate": 47.5,               # "do nothing" hit rate on the full df
    "vs_baseline": 12.5,                     # test hit_rate - baseline_hit_rate, in points
}
```

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.

---

## A good alternative choice, at any data size

This isn't tied to a data size at all, it scales from a few dozen rows up
to millions the same way: scan every candidate point, count what's above
and below it, replay the winner on held-out data. The core idea holds no
matter how much data feeds it.

**The core idea: make the spike visible.** Most datasets have some point
along a metric where the outcome rate stops being flat and starts climbing,
but that point is easy to miss just eyeballing a column. `edgepoint` scans
every candidate point along every numeric column and surfaces exactly
where that climb happens, `edge_point`, `hit_rate`, `gap`, `coverage`, in a
plain table. No hidden weights, no coefficients to decode, just "here's
the row count on each side of this line, and here's how much better things
get above it."

**Built to keep re-checking rather than lock in.** Data keeps arriving,
and a good threshold today can shift as more of it comes in. Rather than
fitting one answer and treating it as settled, `search()` is meant to be
re-run as new rows accumulate, each run rechecks the spike against a
fresh train/test split and tells you plainly if it moved, held, or
disappeared. See "Using the result correctly" above, that habit of
recomputing is what keeps the picture honest over time, regardless of
how many rows are behind it.

**Worth knowing on any dataset.** A good-looking gap can sometimes be a
handful of rows doing all the work rather than a real, reliable spike,
this shows up more easily on a smaller slice of data, but the same logic
applies at any scale. `edgepoint` leans against this two ways:
`min_coverage` refuses to let a threshold win on too few rows in the
first place, and the built-in shrinkage discount (see Design notes)
discounts small-sample gaps before they're allowed to win. Still worth a
glance at the `n` column yourself, if a combo's win is resting on very
few rows relative to the rest of your data, that's exactly the kind of
thing the recompute habit above is there to confirm or correct as more
data comes in.

---

## Who this is for

Solo builders, indie quants, and analysts who want to see exactly where a
metric's outcome rate spikes, in a table they can read and trust at a
glance, without needing a modeling toolbox to get there. This is for people
closer to how it was actually built: readers who'd rather audit code they
fully understand 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

**What does edgepoint actually give me?**
A plain, readable table of exactly where an outcome starts spiking along
a metric, plus proof that the spike held up on data it hadn't seen. No
coefficients or hidden weights to decode, just an edge point, a hit rate,
and a coverage number.

**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
