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

Built for data analysts who aren't data scientists, people who know a little Python but don't want to build or babysit a modeling toolbox, and who need a quick, accurate read to make a decision now.

Finds the point in a numeric column where a binary outcome (a `hit`/`0`-`1` column) starts performing meaningfully better - above it, below it, or inside a range, whichever direction the data actually supports - plus the best combination of columns for that. Splits your data into train/test, searches train, replays the exact result on test, so what you get back is checked against data it never saw, not just fit once and trusted.

**Where this applies**: any numeric metric + binary outcome pair:

- Marketing: what engagement score is where churn drops off
- Credit: what score is where default rate becomes acceptable
- Healthcare: what biomarker level is where an outcome rate jumps
- Product: what usage count is where upgrade-to-paid spikes
- Trading / betting: what signal strength is where a pick's hit rate gets reliable (the original itch this scratched)

**Who's this for**: anyone who wants a plain "here's exactly where the line is, and proof it held up on data it hadn't seen" instead of a black-box model, and doesn't want to babysit a modeling toolbox to get there.

**Two things changed in this version.** It's no longer one-directional; it used to only check "does it get better above this point," now it checks every direction (above, below, or a range) and picks whichever one actually holds. And it's no longer a plain function (`edgepoint.search(df, ...)`), it's wrapped in a class called `Engine`, which also saves/loads results to disk for you. If you're used to the old style, read on, both are different now.

---

## Install

```
pip install edgepoint
```

```python
from edgepoint import Engine
```

---

## Quick start

```python
from edgepoint import Engine

engine = Engine(dir_name="my_dataset")   # dir_name = where results get saved

result = engine.train(df, outcome_col="hit")

result.edgepoints   # per-column thresholds (DataFrame)
result.combos       # best combo(s) of thresholds (dict or list of dicts)
```

Everything below is a method on `Engine`. Create one `Engine` per dataset (or reuse one and pass `dir_name=` per call if you're juggling several).

---

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

Before `train()` 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 as a win or a loss, a pass or a fail. `edgepoint` doesn't decide what "success" looks like in your data, 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 number the library produces after that 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," `0` (or `False`) meaning "this row doesn't." 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.

---

## Creating an Engine

```python
engine = Engine(
    dir_name=None,        # dataset folder name; None = don't save to disk
    range_bins=15,        # number of candidate edges checked per column
    min_coverage=33,      # min % of rows a threshold must cover, 5-100
    gap_weight=100,       # 0-100, how much weight gap gets vs coverage
    shrinkage_k=30,       # small-sample discount strength
    show_progress=True,   # print progress as it runs
    date_match="closest", # how read_file/read_combo fall back if a date is missing
    max_lookback_n=30,    # how many days to look back/forward for date_match
    retain_saves_n=None,  # cap on saved dates kept per dataset; None = keep all
)
```

Every one of these is checked when you set it, pass the wrong type (like a bool where a number's expected) and you'll get a clear error immediately, not a confusing crash later.

### Overfitting: gap_weight and min_coverage are a pair

They're not two separate knobs. The defaults (`gap_weight=100`, `min_coverage=33`) put full weight on the gap itself, backed by a coverage floor that's already reasonably high, a safe, trustworthy starting point. If you lower `min_coverage` to let thinner slices of data qualify, bring `gap_weight` down with it: `60`, `70`, or `80` is a good range. Leaving `gap_weight=100` while dropping `min_coverage` low lets an impressive-looking gap on just a handful of rows win purely on gap size, the exact overfitting risk this pairing is there to guard against.

---

## `update()`: change settings without rebuilding

```python
engine.update(min_coverage=25, show_progress=False)
```

Same checks as the constructor. Only touches the settings listed above, nothing else.

---

## `train()`: run the search, optionally save it

```python
result = engine.train(
    df,
    outcome_col="hit",
    top_combo_n=3,       # how many top combos to keep
    dir_name=None,        # overrides the Engine's dir_name for this call
    date=None,            # defaults to today; used as the save's date stamp
    show_progress=None,   # overrides the Engine's default for this call
    verbose=False,        # passed through to the underlying search
    overwrite=False,      # False = reuse an existing save for that date instead of rerunning
)
```

Returns a `TrainResult` with `.edgepoints` and `.combos` (also unpacks as `edgepoints, combos = engine.train(df)` if you prefer that).

If `dir_name` resolves to a real name, this also saves the results to disk, dated with today (or whatever `date=` you gave). Calling `train()` again for the same date does nothing but read back the old save, unless you pass `overwrite=True`.

---

## `read_file()`: read back a saved run's thresholds

```python
df = engine.read_file(
    dir_name=None,
    date=None,            # defaults to today
    show_progress=None,
    max_lookback_n=None,
    date_match=None,      # "exact", "backward_first", "forward_only", "closest", "newest"
)
```

Returns a DataFrame (empty if nothing was found). If the exact date isn't saved, it falls back using `date_match`, e.g. `"closest"` checks nearby dates on both sides and takes the nearest one.

---

## `read_combo()`: read back a saved run's best combo(s)

```python
combos = engine.read_combo(
    dir_name=None,
    date=None,
    show_progress=None,
    max_lookback_n=None,
    date_match=None,
)
```

Same date-fallback behavior as `read_file()`. Returns a dict (single combo) or list of dicts, or `{}` if nothing found.

---

## `predict()`: check one row of data against a saved combo

```python
result = engine.predict(
    row,                  # dict of {column: value}, or a single-row DataFrame
    dir_name=None,
    date=None,
    check_n=3,             # how many saved top combos to check
    strict_n=2,            # how many of those must pass for an overall pass
    pick_metric="hit_rate",  # "hit_rate" or "coverage" - what to pick the winner by
    pick_on="train",         # "train" or "test" - which side of that metric to use
    show_progress=None,
    max_lookback_n=None,
)
```

Returns `{"status": bool, "combos": dict}`.

- If enough combos passed (`status=True`): `combos` is the single winning combo, among the ones that passed, whichever has the lowest `pick_metric` on `pick_on`. Ties break on the other metric (also lowest wins).
- If not enough passed (`status=False`): `combos` is `{}`, empty.

---

## `delete_combos()`: remove a saved date

```python
engine.delete_combos(date, dir_name=None, show_progress=None)
```

`date` is required (no "today" default here, you have to name what you're deleting). Deletes both the saved results file and combo file for that exact date.

---

## `list_dir()`: list every saved date for a dataset

```python
engine.list_dir(dir_name=None, show_progress=None)
```

Returns a list of `{"name": ..., "days_ago": ...}`, oldest first.

---

## Correlation, not causation

`edgepoint` finds where an outcome rate changes along a metric, it doesn't tell you *why*. A threshold that looks great on train and holds up on test is still just an association, not proof that crossing that point *causes* the better outcome. There could be a third factor driving both. Treat what it returns as "here's where the pattern sits and how well it held up," not "here's a lever you can pull."

That ties into the same habit as the coverage/gap_weight guidance above: the result is a snapshot worth trusting *as a snapshot*, not a mechanism you've proven. It's also not permanent, re-run it as new data comes in, since a threshold that held today can shift as more rows accumulate.

---

## Notes

- `dir_name` is just a folder name under a fixed root, it's not a file path, and results/combos live in their own subfolders under it.
- Everything above validates its own inputs. Pass a bad type or an out-of-range value and you'll get a clear error naming the exact param and what's expected.
- `retain_saves_n` is the cleanup knob: set it once and older saves beyond that count get deleted automatically the next time `train()` writes a new one.

---

No coefficients to decode, no black box, just "here's the line, and here's the proof it held." Point it at a metric and a `hit` column and let it do the counting for you.

## License

MIT
