Skip to main content

sparpartner

A deterministic stratified sampler for train/test prep. It doesn't split your data randomly, it deliberately finds the rows that most resemble a benchmark case, so you can hold those out as a genuine test set and train on everything else.

Why this exists

A random train/test split assumes the test set should look statistically like the train set. That's the wrong question if what you actually want to know is: does the model generalize past one specific profile, or did it just memorize the neighborhood around it?

sparpartner answers that by ranking every row in your data by how closely it resembles a benchmark ("bench_marks") you define, then sorting on that resemblance. You then slice the sorted frame yourself:

  • Lookalikes as test, the rest as train: train on rows unlike the benchmark, test on the rows that are like it. This is the harder, more honest check: it tells you whether the model actually learned something general, or only performs well near cases it's already seen a lot of.
  • Lookalikes as train, the rest as test: the reverse, if you want to check the opposite direction.

sparpartner only produces the ranking. The actual train/test cut is a plain slice on your side (see Usage below).

Where the idea comes from

Two unrelated places, both about comparing something against a reference on purpose rather than at random:

A boxer preparing for a match. A fighter in camp doesn't spar with whoever's free in the gym, they specifically look for a sparring partner who moves, reaches, and hits like the opponent they're about to face. Training against a random partner tells you nothing about how you'll actually do; training against someone who resembles the real threat does. sparpartner applies that same logic to a model: instead of a random holdout, it finds the rows that resemble the toughest, most relevant "opponent" profile and holds those back as the real test, so what's left to train on is everything unlike that opponent, and the test genuinely checks whether the model can handle the match it's actually walking into.

Astrology's approach to comparing a chart to a reference. Reading a chart against a benchmark isn't a single yes/no match, it's several weighted dimensions (sun sign, moon sign, rising, houses, and so on) each compared individually, then combined into one overall resemblance reading. No single dimension decides the outcome alone; the composite is what matters. That's the same shape sparpartner uses: several weighted signals, each scored independently against a benchmark, summed into one composite _score rather than a single all-or-nothing match.

How the scoring works

You give it:

  • df: your data
  • bench_marks: a dict of {column_name: benchmark_value}, one entry per signal you care about
  • custom_weights: a dict of {column_name: weight}, saying which columns matter and how much. Insertion order is preserved and drives both the show_progress readout order and, combined with descending weight, the tie-break cascade order (see below).

For each weighted column, sparpartner auto-detects the column's type and scores every row's distance to the benchmark on a 0–1 scale (1.0 = exact match, 0.0 = as far as possible):

Detected type How distance is measured
numeric abs(value - bench), capped by the column's own max observed distance from bench
date both sides converted to "age in days" relative to the benchmark date, capped by the column's own max observed age
string exact match = 1, anything else = 0

Date detection is automatic. A column is only treated as a date if its values look date-shaped (contain a separator like -, /, . or a recognizable month name) and parse successfully at least 98% of the time. Bare numeric-looking strings (e.g. "12345") never even reach the date-parsing attempt, and object-dtype columns of digit strings fall through to the exact-match string path instead of being mistaken for numbers. Only a real numeric dtype gets the numeric path.

Each column's 0–1 score is multiplied by its weight and summed into one raw score per row. That raw sum is divided by the total weight to get the final _score, but only if the total weight is > 0 — in that normal case _score always lands in the 0–1 range, however many signals or weights you used. If the weights sum to <= 0, normalization is skipped entirely and _score is left as the raw, unnormalized weighted sum (not guaranteed to fall in 0–1).

Tie-breaking

Rows that land on the exact same _score aren't left to random or arbitrary order. Ties are broken by the per-signal score of the highest-weight signal first, then the next-highest, cascading down the weight-sorted signal list until the tie resolves. Signals that share the same weight are compared in the order they appear in custom_weights (dict insertion order). The tie-break always sorts in the same direction as the main score (see look_alike below). Only if every signal is exhausted and rows are still tied does it fall back to pandas' stable sort (original row order).

look_alike

Controls sort direction:

  • look_alike=True: highest _score (closest to benchmark) sorted to the top.
  • look_alike=False (default): highest _score sorted to the bottom, farthest-from-benchmark rows come first.

The sparring report

sample() doesn't just return the ranked frame, it returns a (df, sparring_report) tuple. sparring_report is a flat dict summarizing match quality, e.g.:

{
    "age": 55.0, "income": 57.5,        # one key per signal, avg score
    "min_score": 0.0,
    "max_score": 91.93,
    "mean_score": 56.45,
}

Every value is on a 0–100 scale (100 = perfect match to benchmark) and rounded to 2 decimal places, regardless of return_score.

By default (sparring_n=None) the report is computed over all rows. Pass an int to compute it only over the top sparring_n rows — always ranked by _score descending (the rows that most resemble the benchmark), regardless of the look_alike setting used for the returned df's sort order, so the report's meaning doesn't silently flip when look_alike=False reverses the display order.

Usage

Sample usage

import pandas as pd
from sparpartner import sample

df = pd.DataFrame({
    "id": [1, 2, 3, 4, 5],
    "age": [25, 30, 47, 52, 33],
    "signup_date": ["2023-01-15", "2023-03-02", "2022-11-20", "2023-01-10", "2023-06-01"],
    "country": ["US", "US", "CA", "US", "MX"],
})

bench_marks = {
    "age": 30,
    "signup_date": "2023-01-01",
    "country": "US",
}

custom_weights = {
    "age": 2,
    "signup_date": 1,
    "country": 1,
}

result, sparring_report = sample(
    df,
    bench_marks,
    custom_weights,
    look_alike=True,      # True = most similar rows first
    show_progress=True,   # prints the full scoring breakdown
    return_score=True,    # keep _score columns in the output
    drop_nan=True,
    sparring_n=None,      # None = report computed over all rows
)

print(result)
print(sparring_report)

age=30, signup_date="2023-01-01", and country="US" closely match row id=1 (age 25, close date, US), so with look_alike=True that row lands at or near the top of the sorted output. Flip look_alike=False to instead surface the rows that look least like the benchmark (e.g. the Mexico row with the furthest signup date).

# --- post-sample: turn the ranking into an actual train/test split ---
# look_alike=False sorts lookalikes to the BOTTOM of `ranked`.
ranked, _ = sample(df, bench_marks, custom_weights, look_alike=False)

# Slice however large you want the test set to be, e.g. the bottom 30%:
cut = int(len(ranked) * 0.7)
train = ranked.iloc[:cut]   # non-lookalikes
test = ranked.iloc[cut:]    # lookalikes, the harder, honest test set

Parameters

Name Type Default What it does
df DataFrame required Must contain a column for every name (key) in custom_weights
bench_marks dict required {column_name: benchmark_value}
custom_weights dict of {name: weight} required Which columns to score and how much each contributes
look_alike bool False True = lookalikes sorted to top; False = sorted to bottom
show_progress bool False Prints a full readout, in run order: header, per-column type/cap/sample scores, drop_nan check (if drop_nan=True), normalize check, sort-apply readout, top/bottom N ranked rows with weighted contributions, and the sparring report itself, flagging any signal contributing zero separation
return_score bool False If True, keeps _score and _score_<name> columns in the returned df instead of dropping them
drop_nan bool True If True, drops any row with a NaN in a per-signal _score_<name> column or in _score itself, after printing (if show_progress) a sanity check of what was dropped and why
sparring_n int or None None None = sparring report computed over all rows; an int computes it over just the top sparring_n rows by _score

Returns

sample() returns a (df, sparring_report) tuple, not just a DataFrame — see The sparring report above.

Validation

Input validation runs upfront, before any scoring starts, in two passes: general parameter checks, then custom_weights checks.

Raises TypeError if:

  • df isn't a pandas DataFrame
  • bench_marks isn't a dict
  • custom_weights isn't a dict
  • look_alike, show_progress, return_score, or drop_nan isn't a bool
  • sparring_n isn't an int or None (bools are rejected too)

Raises ValueError if:

  • df has no rows
  • custom_weights is an empty dict
  • sparring_n isn't a positive integer
  • a custom_weights key isn't a string, or doesn't match a column in df
  • a custom_weights key has no matching entry in bench_marks
  • a weight isn't numeric (bools are rejected too, a bool is technically an int in Python but was never meant as a weight)
  • a weight is NaN, inf, or -inf

Note on duplicate signal names: since custom_weights is now a dict, keys are inherently unique, so a repeated column name can no longer be passed in the first place, Python itself resolves a repeated key in a dict literal (keeping only the last value) before sample() ever sees it. There's nothing left for validation to catch here.

A couple of things worth knowing

  • Object-dtype numeric strings: a column of strings like "100", "200" (object dtype, no separator) is scored as an exact-match string column, not auto-converted to numeric. Only genuine numeric dtypes (int, float) get the numeric distance path.

Metadata

Release files for sparpartner 0.1.5

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for sparpartner 0.1.5
File Size Uploaded Provenance
sparpartner-0.1.5.tar.gz 20.6 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for sparpartner 0.1.5
File Interpreter ABI Platform
sparpartner-0.1.5-py3-none-any.whl Python 3 none any Details

Total release size: 37.1 kB

Release files / sparpartner-0.1.5.tar.gz

Download URL sparpartner-0.1.5.tar.gz
Size 20.6 kB
Tags Source
SHA-256 checksum
How to use checksums
dd0606aedeaae9f4710dab9ea95c5d7a9ff4570337632b133310aea1df864b83
BLAKE2b-256 checksum
How to use checksums
be6d204e994f46dca502a4b349bc0c43aa7652dbfae4cd2d4854dbbae19402a1
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.9

Release files / sparpartner-0.1.5-py3-none-any.whl

Download URL sparpartner-0.1.5-py3-none-any.whl
Size 16.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
76c0007e344981c8689b2b86a16175b0815d8886432c0458e026b17f3d0af4a0
BLAKE2b-256 checksum
How to use checksums
d747e38633f8b3dfa0ea61130bad91d091e9623e4282881939b5b8de1e0d8f82
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.9

Security for sparpartner 0.1.5

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page