Metadata-Version: 2.4
Name: edgepoint
Version: 3.5.4
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.

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

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

edgepoint is built to optimize well on small data, shrinkage, k-fold 
survival, and the baseline gate all exist so a thin, noisy result doesn't 
get to rank well. To get the best out of it on small data, if you have 
domain knowledge, supervise which columns you pass in, If you don't have
that knowledge, let edgepoint do its magic.

---

## 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 `0`s or almost all `1`s) leave little contrast
  to find an edge across, and columns are likely to get skipped for
  failing `min_coverage`.

---

## Quick start

```python
import pandas as pd
from edgepoint import search

df = pd.DataFrame({
    "score": [
        63.94, 22.32, 89.22, 2.98, 2.65, 54.49, 80.94, 69.81,
        95.72, 9.67, 80.71, 97.31, 82.94, 57.74, 22.79, 23.28,
        63.57, 20.95, 64.80, 72.91, 98.95, 68.46, 22.90, 26.77,
        87.64, 39.56, 26.49, 26.27, 39.94, 50.95, 10.96, 42.22,
        99.61, 86.08, 68.17, 64.10, 45.37, 26.34, 91.26, 63.89
    ],
    "age": [
        19.2, 52.6, 22.1, 28.3, 27.3, 28.4, 18.3, 34.0,
        33.8, 57.8, 52.3, 35.8, 47.1, 51.1, 31.6, 22.7,
        35.1, 30.5, 46.6, 25.7, 48.1, 57.6, 19.5, 27.9,
        32.8, 61.0, 29.6, 45.5, 28.3, 22.3, 47.5, 21.0,
        42.9, 18.5, 43.2, 23.2, 62.8, 41.5, 58.9, 46.6
    ],
    "hit": [
        1, 0, 0, 0, 0, 0, 0, 1,
        1, 0, 1, 0, 0, 1, 1, 0,
        1, 0, 1, 1, 1, 1, 0, 0,
        0, 0, 0, 0, 0, 1, 0, 0,
        0, 0, 1, 0, 0, 1, 1, 1
    ]
})

edgepoints_df, top_combos = search(
    df,
    outcome_col="hit",
    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()` takes `df` exactly 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, **shuffle `df` yourself 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 all `0`s or almost all
  `1`s.

---

## How the pipeline works

1. **Data sanity / splitting**: `df` is cleaned first. Invalid outcome
   rows dropped, `True`/`False` normalized to `1`/`0`, non-numeric,
   constant, or near-unique predictor columns dropped, remaining
   `NaN`/`inf` filled. The cleaned data is then split into train/test
   automatically, chronologically, based on row count, with no split
   ratio to configure.
2. **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). Columns that clear the
   baseline are then combined into AND-combinations, capped at a combo
   size **worked out automatically** (see below), and k-fold validated
   on train itself.
3. **Test section**: every train-side edge and surviving combo is
   replayed against test, unchanged, with its own k-fold validation.
4. **Merge edges**: train and test side stats for each column are
   merged side by side (`_tr`/`_te` suffixes) into `edgepoints_df`,
   ranked by a blended `final_score`.
5. **Merge combos**: same idea, but only combos that qualified on
   *both* train and test survive.
6. **Top combos**: the best `top_combo_n` combos are printed and
   returned as `top_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.

---

## Automatic combo-size capping (no `mode` to set)

Earlier versions asked you to pick a `mode` (`"quick"` / `"standard"` /
`"deep"`) to control how large a column combination could get. That's
gone. **The cap is now worked out automatically for every run**, from
two independent limits, and the smaller one always wins:

- **Row cap**: how big a combo can you actually *trust* given how much
  data you have? ANDing four conditions together on 76 rows slices the
  data down to almost nothing per combo, that's a straight overfitting
  risk, not a compute one. This cap comes purely from row count.
- **Memory cap**: how big a combo can the machine *afford* to generate
  before the number of combinations blows past a fixed safety ceiling?
  This cap comes purely from column count, nothing statistical about it.

`search()` always uses `min(row_cap, memory_cap)`, so a combo size is
never allowed past whichever limit is more restrictive. This removes a
real footgun: a `mode="deep"` setting on a small dataset used to let
combo size grow well past what the row count could statistically
support, manufacturing an impressive-looking combo that was really just
overfitting a handful of rows. Now that ceiling is enforced automatically
on every run, there's no setting to accidentally crank up and no need to
know how many rows is "enough" for a given combo size, `search()` works
it out for you and never lets you shoot yourself in the foot.

---

## Parameters

| Parameter      | Default | Description                                                                 |
| -------------- | ------- | ----------------------------------------------------------------------------- |
| `df`           | required | Input DataFrame                                                              |
| `outcome_col`  | `"hit"` | Binary outcome column (`0`/`1`, `True`/`False`, or a mix)                     |
| `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:

```python
{
    "combo_num": 2,
    "combo": "score, age",
    "edges": {"score": 55.0, "age": 40.0},
    "hit_rate_tr": 83.33, "hit_rate_te": 75.0,
    "coverage_tr": 20.0, "coverage_te": 40.0,
    "n_tr": 6, "n_te": 4,
    "score_tr": 0.71, "score_te": 0.55,
    "final_score": 64.6,
    "baseline_full": 40.0,
    "vs_baseline": 35.0,
}
```

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 `score >= 55` alone gets you a
66.67% hit rate on train (covering half the rows) and `age >= 40` alone
only gets you 50%, useful, but each is still riding along with a lot of
noise. A combo sharpens that: `score >= 55.0 AND age >= 40.0` comes back
at an 83.33% hit rate on train and 75.0% on test, covering a thinner but
much sharper 20-40% of rows, against a 40% baseline. That's the real
claim, it's not score alone or age alone, it's that the two *stacked
together* is where the actual separation was hiding, and it held up on
data the search never saw (`vs_baseline: 35.0` means even the weaker of
the two splits still beat "doing nothing" by 35 points). `top_combos`
typically hands you a few of these to compare, so you can weigh one
combo's strength against another's, and pick whichever 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_coverage` is 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_score` is
  halved for that row.
- **Automatic combo-size capping**: combo size is capped by the
  stricter of a row-count-based statistical limit and a memory-based
  safety ceiling, see "Automatic combo-size capping" above, there's no
  `mode` to set and no way to override this cap.
- **One-directional search**: `edgepoint` only 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
