Skip to content

Algorithms

The registry

thresher.algorithm

The registry of selectable algorithms, and lookup by name.

Algorithm

Bases: NamedTuple

A selectable algorithm.

Attributes:

Name Type Description
id str

the canonical short name, and the key in available_algorithms.

full_name str

human-readable name, used in verbose output.

synonyms list[str]

alternative names accepted by retrieve_by_alias.

data_vol_thresh int

input size beyond which this algorithm is slow enough to be worth warning about. run_computations logs a warning above it, so nobody starts a run that will take far longer than they expect.

Each value is roughly where a run passes ten seconds, extrapolated from the timings in examples/benchmark.py on one laptop. They are order-of-magnitude guidance rather than promises - a faster machine moves them all up - which is why crossing one is a warning rather than a refusal.

retrieve_by_alias

retrieve_by_alias(name: str) -> Algorithm

Resolve an algorithm by its id or by one of its synonyms.

Parameters:

Name Type Description Default
name str

an algorithm id such as 'grid', or a synonym such as 'sim'. Matched case-insensitively, ids first and synonyms second.

required

Returns:

Type Description
Algorithm

The matching Algorithm from available_algorithms.

Raises:

Type Description
UnknownAlgorithmError

if the name matches nothing - including anything that is not a string at all, which previously escaped as a bare AttributeError from .lower(). It is a ValueError, and carries .name and .available alongside the message.

Source code in src/thresher/algorithm.py
def retrieve_by_alias(name: str) -> Algorithm:
    """Resolve an algorithm by its id or by one of its synonyms.

    Args:
        name: an algorithm id such as `'grid'`, or a synonym such as `'sim'`. Matched
            case-insensitively, ids first and synonyms second.

    Returns:
        The matching `Algorithm` from `available_algorithms`.

    Raises:
        UnknownAlgorithmError: if the name matches nothing - including anything that is
            not a string at all, which previously escaped as a bare `AttributeError`
            from `.lower()`. It is a `ValueError`, and carries `.name` and `.available`
            alongside the message.
    """
    if not isinstance(name, str):
        raise UnknownAlgorithmError(name, available_algorithms)
    name = name.lower()
    try:
        return available_algorithms[name]
    except KeyError:
        # try to match by the 'alternate name'
        try:
            return next(_ for _ in available_algorithms.values() if name in _.synonyms)
        except StopIteration:
            # 'next' on an exhausted generator raises StopIteration, which says nothing
            # about what went wrong and reads as a bug rather than a bad argument.
            raise UnknownAlgorithmError(name, available_algorithms) from None

Exact sweep

thresher.algs.exact.compute.run

run(
    scores: Sequence[float],
    actual_classes: Sequence[int],
    progress_bar: bool,
    alg_options: Mapping[str, Any],
    backend: Backend | None = None,
) -> float

Find the threshold with the highest accuracy, exactly.

Parameters:

Name Type Description Default
scores Sequence[float]

the values being split.

required
actual_classes Sequence[int]

the matching ground-truth classes, as -1 and 1.

required
progress_bar bool

draw a progress bar on stderr.

required
alg_options Mapping[str, Any]

accepted for signature compatibility with the other solvers. This algorithm has nothing to tune - it is exact, so there is no accuracy to trade against speed.

required
backend Backend | None

where the counting happens. Defaults to in-process. Only the counting is distributed; the sweep over distinct scores is trivial by comparison and stays on the driver.

None

Returns:

Type Description
float

A threshold yielding the highest achievable fraction of correctly classified

float

samples - the best that exists, over every split a threshold can induce.

float

Interior results are the midpoint between the two scores they separate, matching

float

linear search. Two results sit at the edges: max(scores) classifies everything

float

negative, and a value just below min(scores) classifies everything positive.

float

The latter is the only result that can fall outside the span of the input, and it

float

is returned only when it beats every threshold inside it, which needs data where

float

score and class run contrary to each other.

Raises:

Type Description
InsufficientDataError

if no scores were given. It is a ValueError.

Source code in src/thresher/algs/exact/compute.py
def run(
    scores: Sequence[float],
    actual_classes: Sequence[int],
    progress_bar: bool,
    alg_options: Mapping[str, Any],
    backend: Backend | None = None,
) -> float:
    """Find the threshold with the highest accuracy, exactly.

    Args:
        scores: the values being split.
        actual_classes: the matching ground-truth classes, as -1 and 1.
        progress_bar: draw a progress bar on stderr.
        alg_options: accepted for signature compatibility with the other solvers. This
            algorithm has nothing to tune - it is exact, so there is no accuracy to trade
            against speed.
        backend: where the counting happens. Defaults to in-process. Only the counting is
            distributed; the sweep over distinct scores is trivial by comparison and stays
            on the driver.

    Returns:
        A threshold yielding the highest achievable fraction of correctly classified
        samples - the best that exists, over every split a threshold can induce.

        Interior results are the midpoint between the two scores they separate, matching
        linear search. Two results sit at the edges: `max(scores)` classifies everything
        negative, and a value just below `min(scores)` classifies everything positive.
        The latter is the only result that can fall outside the span of the input, and it
        is returned only when it beats every threshold inside it, which needs data where
        score and class run contrary to each other.

    Raises:
        InsufficientDataError: if no scores were given. It is a `ValueError`.
    """
    # Length rather than truthiness: `not array` is ambiguous for a numpy array of more
    # than one element, and raises. Since 0.7.2 the input reaches here as the caller's own
    # container, so it need not be a list.
    if len(scores) == 0:
        raise InsufficientDataError("At least one score is needed to evaluate a threshold.")

    # The sweep needs only the class counts at each distinct score, never the samples
    # themselves - which is precisely what makes it distributable.
    counts = (backend or LocalBackend()).class_counts_by_score(scores, actual_classes)

    log.info("Sweeping {} distinct scores from {} samples for the exact optimum.", len(counts), len(scores))

    best_threshold, best_correct = sweep_class_counts(counts, progress_bar=progress_bar)

    log.info("Best threshold {} classifies {}/{} correctly.", best_threshold, best_correct, len(scores))

    return best_threshold

Histogram sweep

thresher.algs.histogram.compute.run

run(
    scores: Sequence[float],
    actual_classes: Sequence[int],
    progress_bar: bool,
    alg_options: Mapping[str, Any],
) -> float

Find a near-optimal threshold from binned counts.

Parameters:

Name Type Description Default
scores Sequence[float]

the values being split.

required
actual_classes Sequence[int]

the matching ground-truth classes, as -1 and 1.

required
progress_bar bool

draw a progress bar on stderr.

required
alg_options Mapping[str, Any]

recognised keys, falling back to the module-level default: no_of_bins (1024) sets the resolution. The returned threshold is within one bin width of the best one, so doubling this halves the worst-case error and costs one more counter per bin - and nothing per row.

required

Returns:

Type Description
float

The best threshold the binning can express: a bin edge, or a value just below the

float

smallest score where classifying everything positive wins. Within one bin width of

float

what exact would return, and identical across runs on the same data.

Raises:

Type Description
InsufficientDataError

if no scores were given, or no_of_bins is below one.

Source code in src/thresher/algs/histogram/compute.py
def run(
    scores: Sequence[float],
    actual_classes: Sequence[int],
    progress_bar: bool,
    alg_options: Mapping[str, Any],
) -> float:
    """Find a near-optimal threshold from binned counts.

    Args:
        scores: the values being split.
        actual_classes: the matching ground-truth classes, as -1 and 1.
        progress_bar: draw a progress bar on stderr.
        alg_options: recognised keys, falling back to the module-level default:
            `no_of_bins` (1024) sets the resolution. The returned threshold is within one
            bin width of the best one, so doubling this halves the worst-case error and
            costs one more counter per bin - and nothing per row.

    Returns:
        The best threshold the binning can express: a bin edge, or a value just below the
        smallest score where classifying everything positive wins. Within one bin width of
        what `exact` would return, and identical across runs on the same data.

    Raises:
        InsufficientDataError: if no scores were given, or `no_of_bins` is below one.
    """
    # Length rather than truthiness - see the note in `algs/exact/compute.py`.
    if len(scores) == 0:
        raise InsufficientDataError("At least one score is needed to evaluate a threshold.")

    bins: int = get_or_default(alg_options, "no_of_bins", no_of_bins_default)
    if bins < 1:
        raise InsufficientDataError(f"no_of_bins must be at least 1, got {bins}.")

    lowest = min(scores)
    highest = max(scores)
    span = highest - lowest

    log.info("Binning {} scores over [{}, {}] into {} bins.", len(scores), lowest, highest, bins)

    negatives = [0] * bins
    positives = [0] * bins

    # The one pass. Every row is read here and never looked at again.
    for score, actual in zip(scores, actual_classes, strict=False):
        index = bin_index(score, lowest, span, bins)
        if actual == POSITIVE_LABEL:
            positives[index] += 1
        else:
            negatives[index] += 1

    best_threshold, best_correct = sweep_bins(
        negatives, positives, lowest=lowest, highest=highest, progress_bar=progress_bar
    )

    log.info("Best threshold {} classifies {}/{} correctly.", best_threshold, best_correct, len(scores))

    return best_threshold

thresher.algs.linear.compute.run

run(
    scores: Sequence[float],
    actual_classes: Sequence[int],
    progress_bar: bool,
    backend: Backend | None = None,
) -> float

Evaluate the midpoint between every pair of adjacent scores, exactly.

Unlike the other solvers this one takes no alg_options; its only parameter, n_jobs, selects run_parallel instead.

Parameters:

Name Type Description Default
scores Sequence[float]

the values being split.

required
actual_classes Sequence[int]

the matching ground-truth classes, as -1 and 1.

required
progress_bar bool

draw a progress bar on stderr. Since 0.4.2 the candidates are scored in one batch, so this brackets the work rather than advancing through it.

required
backend Backend | None

where the counting happens. Defaults to in-process.

None

Returns:

Type Description
float

The midpoint threshold with the highest accuracy. Where several tie, the first one

float

found wins.

Raises:

Type Description
InsufficientDataError

if fewer than two scores were given, leaving no midpoint to evaluate. It is a ValueError.

Source code in src/thresher/algs/linear/compute.py
def run(
    scores: Sequence[float],
    actual_classes: Sequence[int],
    progress_bar: bool,
    backend: Backend | None = None,
) -> float:
    """Evaluate the midpoint between every pair of adjacent scores, exactly.

    Unlike the other solvers this one takes no `alg_options`; its only parameter, `n_jobs`,
    selects `run_parallel` instead.

    Args:
        scores: the values being split.
        actual_classes: the matching ground-truth classes, as -1 and 1.
        progress_bar: draw a progress bar on stderr. Since 0.4.2 the candidates are scored
            in one batch, so this brackets the work rather than advancing through it.
        backend: where the counting happens. Defaults to in-process.

    Returns:
        The midpoint threshold with the highest accuracy. Where several tie, the first one
        found wins.

    Raises:
        InsufficientDataError: if fewer than two scores were given, leaving no midpoint
            to evaluate. It is a `ValueError`.
    """
    batch_size = len(scores)

    log.info(
        "Doing linear search with {} iterations. It can take some time, depending on the data volume.",
        batch_size,
    )

    # Every midpoint between adjacent sorted scores, duplicates included, exactly as
    # before - scoring them is now one batched call instead of a nested loop.
    candidates = [(low + high) / 2 for low, high in pairwise(sorted(scores))]

    if not candidates:
        # 'pairwise' yields nothing for fewer than two scores, so there was no candidate
        # threshold to evaluate at all.
        raise InsufficientDataError("At least two scores are needed to evaluate a threshold.")

    # One batched call, so the bar brackets it rather than stepping through it: 0% while
    # the counting runs, 100% when it returns.
    with make_progress(batch_size, "Linear search", enabled=progress_bar) as bar:
        bar.update(0)
        tallies = (backend or LocalBackend()).tally_candidates(candidates, scores, actual_classes)

    # max() over indices returns the first maximum, keeping the original tie-breaking.
    return candidates[max(range(len(tallies)), key=tallies.__getitem__)]

thresher.algs.linear.compute.run_parallel

run_parallel(
    scores: Sequence[float],
    actual_classes: Sequence[int],
    n_jobs: int,
) -> float

Run the linear search across several processes.

Selected by run_computations when allow_parallel is set and n_jobs != 1.

Since 0.7.0 this is the ordinary search running on the mp backend, rather than a second implementation of it. Two things follow. The answer no longer depends on whether the search was parallelised: this used to evaluate the raw scores as thresholds where the sequential path evaluates the midpoints between them, so the two returned different - though equally valid - answers for the same data. And the __main__ guard that separate processes need is now enforced with an explanation instead of hanging.

Parameters:

Name Type Description Default
scores Sequence[float]

the values being split.

required
actual_classes Sequence[int]

the matching ground-truth classes, as -1 and 1.

required
n_jobs int

number of worker processes, or -1 for every available processor bar one.

required

Returns:

Type Description
float

The threshold with the highest accuracy - the same one the sequential path finds.

Raises:

Type Description
ConfigurationError

if n_jobs is 0 or below -1. It is a ValueError.

ParallelBootstrapError

if the workers could not start, which usually means a missing __main__ guard. It is a RuntimeError.

Source code in src/thresher/algs/linear/compute.py
def run_parallel(scores: Sequence[float], actual_classes: Sequence[int], n_jobs: int) -> float:
    """Run the linear search across several processes.

    Selected by `run_computations` when `allow_parallel` is set and `n_jobs != 1`.

    Since 0.7.0 this is the ordinary search running on the `mp` backend, rather than a
    second implementation of it. Two things follow. The answer no longer depends on
    whether the search was parallelised: this used to evaluate the raw scores as
    thresholds where the sequential path evaluates the midpoints between them, so the two
    returned different - though equally valid - answers for the same data. And the
    `__main__` guard that separate processes need is now enforced with an explanation
    instead of hanging.

    Args:
        scores: the values being split.
        actual_classes: the matching ground-truth classes, as -1 and 1.
        n_jobs: number of worker processes, or -1 for every available processor bar one.

    Returns:
        The threshold with the highest accuracy - the same one the sequential path finds.

    Raises:
        ConfigurationError: if `n_jobs` is 0 or below -1. It is a `ValueError`.
        ParallelBootstrapError: if the workers could not start, which usually means a
            missing `__main__` guard. It is a `RuntimeError`.
    """
    backend = MultiprocessingBackend(num_workers=n_jobs)

    log.info(
        "Doing linear search with {} scores, running in parallel over {} processes.",
        len(scores),
        resolve_worker_count(n_jobs),
    )

    # No bar: the work happens in other processes, and there is no single loop here to
    # step one - see the note in `thresher.progress`.
    return run(scores, actual_classes, progress_bar=False, backend=backend)

thresher.algs.grid.compute.run

run(
    scores: Sequence[float],
    actual_classes: Sequence[int],
    progress_bar: bool,
    alg_options: Mapping[str, Any],
    stochastic: bool = False,
    backend: Backend | None = None,
) -> float

Evaluate every point on a grid spanning the data and keep the best.

Parameters:

Name Type Description Default
scores Sequence[float]

the values being split.

required
actual_classes Sequence[int]

the matching ground-truth classes, as -1 and 1.

required
progress_bar bool

draw a progress bar on stderr.

required
alg_options Mapping[str, Any]

recognised keys, each falling back to its module-level default: no_of_decimal_places (2) sets the grid resolution - the grid holds 10**places + 1 evenly spaced points, so 2 gives 101 of them, spanning the data rather than a fixed interval; stoch_ratio (0.05) is the fraction of data sampled per candidate, used only when stochastic; reshuffle (False) draws a fresh sample for every candidate instead of reusing one, again only when stochastic.

required
stochastic bool

score each candidate against a subsample rather than all the data.

False
backend Backend | None

where the counting happens. Defaults to in-process, and is used only for the exhaustive path - the stochastic one draws its own subsamples, which sharding would change.

None

Returns:

Type Description
float

The grid point with the highest measured accuracy. The grid spans

float

[min(scores), max(scores)], so the resolution is spent on the range the data

float

actually occupies whatever its scale; one further candidate below the minimum

float

expresses "classify everything as positive". Ties go to the leftmost candidate,

float

which keeps the answer inside the data unless the edge is strictly better.

Raises:

Type Description
InsufficientDataError

if the grid yielded no candidates at all. It is a ValueError.

Source code in src/thresher/algs/grid/compute.py
def run(
    scores: Sequence[float],
    actual_classes: Sequence[int],
    progress_bar: bool,
    alg_options: Mapping[str, Any],
    stochastic: bool = False,
    backend: Backend | None = None,
) -> float:
    """Evaluate every point on a grid spanning the data and keep the best.

    Args:
        scores: the values being split.
        actual_classes: the matching ground-truth classes, as -1 and 1.
        progress_bar: draw a progress bar on stderr.
        alg_options: recognised keys, each falling back to its module-level default:
            `no_of_decimal_places` (2) sets the grid resolution - the grid holds
            `10**places + 1` evenly spaced points, so 2 gives 101 of them, spanning the
            data rather than a fixed interval; `stoch_ratio` (0.05) is the fraction of
            data sampled per candidate, used only when `stochastic`; `reshuffle` (False)
            draws a fresh sample for every candidate instead of reusing one, again only
            when `stochastic`.
        stochastic: score each candidate against a subsample rather than all the data.
        backend: where the counting happens. Defaults to in-process, and is used only for
            the exhaustive path - the stochastic one draws its own subsamples, which
            sharding would change.

    Returns:
        The grid point with the highest measured accuracy. The grid spans
        `[min(scores), max(scores)]`, so the resolution is spent on the range the data
        actually occupies whatever its scale; one further candidate below the minimum
        expresses "classify everything as positive". Ties go to the leftmost candidate,
        which keeps the answer inside the data unless the edge is strictly better.

    Raises:
        InsufficientDataError: if the grid yielded no candidates at all. It is a
            `ValueError`.
    """
    best_threshold: float | None = None
    best_accuracy: float = -1.0

    no_of_decimal_places: int = get_or_default(
        alg_options, "no_of_decimal_places", no_of_decimal_places_default
    )
    stoch_ratio: float = get_or_default(alg_options, "stoch_ratio", stoch_ratio_default)
    reshuffle: bool = get_or_default(alg_options, "reshuffle", reshuffle_default)

    batch_size = (10**no_of_decimal_places) + 1

    # Length rather than truthiness - see the note in `algs/exact/compute.py`.
    if len(scores) == 0:
        raise InsufficientDataError("The grid produced no candidate thresholds to evaluate.")

    candidates = _build_grid(scores, batch_size)
    total = len(candidates)

    log.info(
        "Evaluating {} solutions over [{}, {}]. Please wait for results.",
        total,
        min(scores),
        max(scores),
    )

    if not stochastic:
        # The exhaustive path is exactly "score these candidates, keep the best", which is
        # what a backend parallelises. max() over indices takes the first maximum, which
        # is the tie-breaking the loop below also used.
        with make_progress(total, "Grid search", enabled=progress_bar) as bar:
            bar.update(0)
            tallies = (backend or LocalBackend()).tally_candidates(candidates, scores, actual_classes)
        return candidates[max(range(len(tallies)), key=tallies.__getitem__)]

    # Drawn once when every candidate is to be judged against the same subsample; left
    # empty, and never read, in the other two modes.
    one_time_projection: list[tuple[float, int]] = (
        _get_random_projection(scores, actual_classes, stoch_ratio) if stochastic and not reshuffle else []
    )

    with make_progress(total, "Stochastic grid", enabled=progress_bar) as bar:
        for iteration, single_point in enumerate(candidates, start=1):
            bar.update(iteration)

            count_correct, count_incorrect = 0, 0

            projection: Iterable[tuple[float, int]]
            if reshuffle:
                projection = _get_random_projection(scores, actual_classes, stoch_ratio)
            else:
                projection = one_time_projection

            for score, actual in projection:
                predicted = 1 if score > single_point else -1
                if predicted == actual:
                    count_correct += 1
                else:
                    count_incorrect += 1

            accuracy = count_correct / (count_correct + count_incorrect)

            if accuracy > best_accuracy:
                best_threshold, best_accuracy = float(single_point), accuracy

    if best_threshold is None:
        raise InsufficientDataError("The grid produced no candidate thresholds to evaluate.")

    return best_threshold

thresher.algs.grid.compute.run_stoch

run_stoch(
    scores: Sequence[float],
    actual_classes: Sequence[int],
    progress_bar: bool,
    alg_options: Mapping[str, Any],
) -> float

Run the grid search against a random subsample rather than the full dataset.

This is the sgrid algorithm - a thin wrapper that passes stochastic=True into run, since the two share an implementation.

Parameters:

Name Type Description Default
scores Sequence[float]

the values being split.

required
actual_classes Sequence[int]

the matching ground-truth classes, as -1 and 1.

required
progress_bar bool

draw a progress bar on stderr.

required
alg_options Mapping[str, Any]

may hold no_of_decimal_places, stoch_ratio and reshuffle.

required

Returns:

Type Description
float

The grid point with the highest measured accuracy.

Source code in src/thresher/algs/grid/compute.py
def run_stoch(
    scores: Sequence[float],
    actual_classes: Sequence[int],
    progress_bar: bool,
    alg_options: Mapping[str, Any],
) -> float:
    """Run the grid search against a random subsample rather than the full dataset.

    This is the `sgrid` algorithm - a thin wrapper that passes `stochastic=True` into
    `run`, since the two share an implementation.

    Args:
        scores: the values being split.
        actual_classes: the matching ground-truth classes, as -1 and 1.
        progress_bar: draw a progress bar on stderr.
        alg_options: may hold `no_of_decimal_places`, `stoch_ratio` and `reshuffle`.

    Returns:
        The grid point with the highest measured accuracy.
    """
    return run(scores, actual_classes, progress_bar, alg_options, True)

Evolutionary algorithm

thresher.algs.genetic.compute.run

run(
    scores: Sequence[float],
    actual_classes: Sequence[int],
    progress_bar: bool,
    alg_options: Mapping[str, Any],
) -> float

Evolve a population of candidate thresholds and return the fittest one measured.

The initial population is seeded across the range between the mean score of the negative class and that of the positive class, so the search starts where the boundary is likely to lie.

Parameters:

Name Type Description Default
scores Sequence[float]

the values being split.

required
actual_classes Sequence[int]

the matching ground-truth classes, as -1 and 1.

required
progress_bar bool

draw a progress bar on stderr, one step per generation. It is not drawn while the log is at DEBUG, which is where the per-generation detail goes: the two write to the same stream. That rule used to live here and now applies to every solver - see thresher.progress.

required
alg_options Mapping[str, Any]

recognised keys, each falling back to its module-level default: population_size (30) agents per generation; number_of_generations (20) rounds of selection; number_of_iterations (10) fitness samples drawn per agent per generation; sus_factor (2) how many of the least fit are left child-less, and so must be below population_size; stoch_ratio (0.02) fraction of the data each fitness sample reads; mutation_chance (0.05) probability that one agent per generation is nudged; mutation_factor (0.10) how far that nudge can move it, in either direction.

required

Returns:

Type Description
float

The trait of the fittest agent that was actually measured, across every

float

generation. Until 0.7.3 this was the mean of the final population - which is bred

float

after the last round of scoring and so never evaluated at all, letting one

float

crossover and one mutation reach the answer with no selection in front of them.

float

With mutation_chance=1.0 and mutation_factor=50 that returned 1.3188 on data

float

spanning [0, 1].

Raises:

Type Description
ConfigurationError

if any of the four counts is below 1, or if sus_factor would leave no agent to breed from. It is a ValueError.

Source code in src/thresher/algs/genetic/compute.py
def run(
    scores: Sequence[float],
    actual_classes: Sequence[int],
    progress_bar: bool,
    alg_options: Mapping[str, Any],
) -> float:
    """Evolve a population of candidate thresholds and return the fittest one measured.

    The initial population is seeded across the range between the mean score of the
    negative class and that of the positive class, so the search starts where the
    boundary is likely to lie.

    Args:
        scores: the values being split.
        actual_classes: the matching ground-truth classes, as -1 and 1.
        progress_bar: draw a progress bar on stderr, one step per generation. It is not
            drawn while the log is at DEBUG, which is where the per-generation detail
            goes: the two write to the same stream. That rule used to live here and now
            applies to every solver - see `thresher.progress`.
        alg_options: recognised keys, each falling back to its module-level default:
            `population_size` (30) agents per generation; `number_of_generations` (20)
            rounds of selection; `number_of_iterations` (10) fitness samples drawn per
            agent per generation; `sus_factor` (2) how many of the least fit are left
            child-less, and so must be below `population_size`; `stoch_ratio` (0.02)
            fraction of the data each fitness sample reads; `mutation_chance` (0.05)
            probability that one agent per generation is nudged; `mutation_factor` (0.10)
            how far that nudge can move it, in either direction.

    Returns:
        The trait of the fittest agent that was actually measured, across every
        generation. Until 0.7.3 this was the mean of the final population - which is bred
        after the last round of scoring and so never evaluated at all, letting one
        crossover and one mutation reach the answer with no selection in front of them.
        With `mutation_chance=1.0` and `mutation_factor=50` that returned 1.3188 on data
        spanning [0, 1].

    Raises:
        ConfigurationError: if any of the four counts is below 1, or if `sus_factor` would
            leave no agent to breed from. It is a `ValueError`.
    """
    # Every size the simulation runs on is checked before any of it starts, so a setting
    # that cannot work is reported as such rather than as whatever the arithmetic made of
    # it several seconds in.
    population_size = _positive_count(
        "population_size",
        "agents per generation",
        get_or_default(alg_options, "population_size", population_size_default),
    )
    number_of_generations = _positive_count(
        "number_of_generations",
        "rounds of selection",
        get_or_default(alg_options, "number_of_generations", number_of_generations_default),
    )
    number_of_iterations = _positive_count(
        "number_of_iterations",
        "fitness samples drawn per agent per generation",
        get_or_default(alg_options, "number_of_iterations", number_of_iterations_default),
    )
    # how many agents should die child-less after a generation
    survivor_count = _survivor_count(
        population_size, get_or_default(alg_options, "sus_factor", sus_factor_default)
    )

    population_initial_range = (
        calculate_range_mean(scores, actual_classes, -1),
        calculate_range_mean(scores, actual_classes, 1),
    )

    stoch_ratio: float = get_or_default(alg_options, "stoch_ratio", stoch_ratio_default)
    # random ratio - the lower, the faster sim

    mutation_factor: float = get_or_default(alg_options, "mutation_factor", mutation_factor_default)

    # Build the population
    population = [
        Agent(
            id=f"agent_{i}",
            trait=random.uniform(population_initial_range[0], population_initial_range[1]),
        )
        for i in range(population_size)
    ]

    # The answer comes from here rather than from wherever the simulation happens to stop,
    # for the same reason the sgd walk returns its best point rather than its last: fitness
    # is sampled, so a generation can be worse than one already seen.
    best_trait, best_fitness = population[0].trait, math.inf

    # Each generation is bred from the previous one's survivors and then scored, so every
    # population that exists has been measured by the time the loop ends. Breeding at the
    # *end* of a generation instead left the last one unscored, which is where an
    # unmeasured crossover and mutation used to reach the answer.
    survivors: list[Agent] = []

    log.info("Evolving {} agents over {} generations.", population_size, number_of_generations)

    with make_progress(number_of_generations, "Evolving", enabled=progress_bar) as bar:
        for generation_no in range(number_of_generations):
            log.debug("Running generation no {}", generation_no)
            bar.update(generation_no)

            if survivors:
                # do crossover
                population = []
                for i in range(population_size):
                    left = random.sample(survivors, 1)[0].trait
                    right = random.sample(survivors, 1)[0].trait
                    if left > right:
                        left, right = right, left
                    new_trait = left + ((right - left) * random.random())
                    population.append(Agent(id=f"agent_{i}", trait=new_trait))

                if random.random() < get_or_default(alg_options, "mutation_chance", mutation_chance_default):
                    _mutate(population, mutation_factor)

                # Asked before it is built: this line lists every agent's trait, so at any
                # other level it would be a list comprehension over the whole population,
                # formatted and thrown away, once per generation.
                if log.is_enabled_for("debug"):
                    log.debug("Population for gen: {} - {}", generation_no, [_.trait for _ in population])

            for _iteration_no in range(number_of_iterations):
                for agent in population:
                    # for every iteration, get a stochastic fitness score
                    agent.samples.append(
                        stochastic_process(agent.trait, scores, actual_classes, random_factor=stoch_ratio)
                    )

            # calculate fitness score - the mean mis-classification ratio over this
            # generation's iterations, so that lower is fitter
            for agent in population:
                agent.fitness = float(np.mean(agent.samples))
                if agent.fitness < best_fitness:
                    best_trait, best_fitness = agent.trait, agent.fitness

            # select most fit (SUS)
            survivors = sorted(population, key=lambda a: a.fitness)[0:survivor_count]

    log.info("Fittest agent measured: {} at a mis-classification ratio of {}", best_trait, best_fitness)

    return best_trait

thresher.algs.genetic.compute.Agent dataclass

Agent(
    id: str,
    trait: float,
    samples: list[float] = list(),
    fitness: float = 0.0,
)

A candidate threshold and its measured fitness.

'samples' and 'fitness' are deliberately separate fields. They were once a single key that started as a list of samples and was overwritten with the aggregate, which is how the fitness ended up being computed from the wrong value entirely (fixed in 0.2.1). Keeping them apart makes that class of mistake impossible to express.

Stochastic gradient descent

thresher.algs.sgd.compute.run

run(
    scores: Sequence[float],
    actual_classes: Sequence[int],
    progress_bar: bool,
    alg_options: Mapping[str, Any],
) -> float

Find a threshold by walking down the error curve from the mean of the scores.

Parameters:

Name Type Description Default
scores Sequence[float]

the values being split.

required
actual_classes Sequence[int]

the matching ground-truth classes, as -1 and 1.

required
progress_bar bool

accepted for signature compatibility with the other solvers; this one reports at DEBUG only and never draws a bar. It stops when it stops improving rather than after a known number of steps, so there is no proportion of the job done for a bar to show.

required
alg_options Mapping[str, Any]

recognised keys, each falling back to its module-level default: num_of_iters (200) caps the number of steps, stop_thresh (0.001) is the improvement below which a step counts as making no progress, stop_patience (3) is how many such steps in a row end the walk, alpha (0.01) damps the step size on each iteration, step_ratio (0.05) sets the first step as a fraction of the score range - so the walk's reach scales with the data rather than assuming it spans about 1 - and stoch_ratio (0.05) is the fraction of the data each step reads. Raising the last is the lever against this algorithm's weak spot: when one class is rare, a small subsample carries little information about where the boundary lies.

required

Returns:

Type Description
float

The best threshold the walk visited, always within [min(scores), max(scores)].

float

This remains the least accurate solver - expect it near the optimum rather than

float

on it, and least reliable when one class is rare, where the subsamples carry

float

little signal about where the boundary lies. Raising stoch_ratio trades speed

float

for a stronger signal, and exact gives up the trade entirely.

Source code in src/thresher/algs/sgd/compute.py
def run(
    scores: Sequence[float],
    actual_classes: Sequence[int],
    progress_bar: bool,
    alg_options: Mapping[str, Any],
) -> float:
    """Find a threshold by walking down the error curve from the mean of the scores.

    Args:
        scores: the values being split.
        actual_classes: the matching ground-truth classes, as -1 and 1.
        progress_bar: accepted for signature compatibility with the other solvers; this
            one reports at DEBUG only and never draws a bar. It stops when it stops
            improving rather than after a known number of steps, so there is no proportion
            of the job done for a bar to show.
        alg_options: recognised keys, each falling back to its module-level default:
            `num_of_iters` (200) caps the number of steps, `stop_thresh` (0.001) is the
            improvement below which a step counts as making no progress,
            `stop_patience` (3) is how many such steps in a row end the walk,
            `alpha` (0.01) damps the step size on each iteration, `step_ratio` (0.05)
            sets the first step as a fraction of the score range - so the walk's reach
            scales with the data rather than assuming it spans about 1 - and
            `stoch_ratio` (0.05) is the fraction of the data each step reads. Raising
            the last is the lever against this algorithm's weak spot: when one class is
            rare, a small subsample carries little information about where the boundary
            lies.

    Returns:
        The best threshold the walk visited, always within `[min(scores), max(scores)]`.
        This remains the least accurate solver - expect it near the optimum rather than
        on it, and least reliable when one class is rare, where the subsamples carry
        little signal about where the boundary lies. Raising `stoch_ratio` trades speed
        for a stronger signal, and `exact` gives up the trade entirely.
    """

    stoch_ratio: float = get_or_default(alg_options, "stoch_ratio", stoch_ratio_default)

    def evaluate_threshold(threshold: float, previous_eval: float) -> tuple[float, float]:
        """Score a threshold against a random subsample, and report the improvement.

        Args:
            threshold: the candidate to evaluate.
            previous_eval: the previous mis-classification ratio, to measure against.

        Returns:
            A `(mis_classification_ratio, gain)` pair, where gain is positive when this
            threshold improved on `previous_eval`.
        """
        log.debug("Currently evaluating threshold: {}", threshold)

        new_eval = stochastic_process(threshold, scores, actual_classes, stoch_ratio)
        gain = previous_eval - new_eval

        return new_eval, gain

    starting_point = float(np.mean(scores))
    log.info("Walking down the error curve from the mean of the scores, {}.", starting_point)

    lower_bound, upper_bound = min(scores), max(scores)

    # A fraction of the range rather than an absolute distance. The step only decays, so
    # a constant 0.05 bounded the walk's total travel at about 4.3 score units however
    # far the optimum actually was: on data spanning thousands it stopped short of the
    # boundary every run, deterministically. Probability-shaped scores span roughly 1, so
    # the default still starts at ~0.05 for them and nothing changes there.
    step_ratio: float = get_or_default(alg_options, "step_ratio", step_ratio_default)
    starting_gradient = step_ratio * (upper_bound - lower_bound)

    num_of_iters: int = get_or_default(alg_options, "num_of_iters", num_of_iters_default)
    stop_thresh: float = get_or_default(alg_options, "stop_thresh", stop_thresh_default)
    stop_patience: int = get_or_default(alg_options, "stop_patience", stop_patience_default)
    alpha: float = get_or_default(alg_options, "alpha", alpha_default)

    return sgd_solver(
        evaluate_threshold,
        starting_point,
        starting_gradient,
        num_of_iters=num_of_iters,
        stop_thresh=stop_thresh,
        alpha=alpha,
        lower_bound=lower_bound,
        upper_bound=upper_bound,
        stop_patience=stop_patience,
    )

thresher.algs.sgd.compute.sgd_solver

sgd_solver(
    eval_func: EvalFunc,
    starting_point: float,
    gradient: float,
    num_of_iters: int,
    stop_thresh: float,
    alpha: float,
    lower_bound: float,
    upper_bound: float,
    stop_patience: int = stop_patience_default,
) -> float

Walk the error curve downhill from a starting point, returning the best point seen.

Each step moves by the current step size and then decays it by alpha; only the direction comes from the measured gain, reversing when a move made things worse. The walk is clamped to [lower_bound, upper_bound] and each step is capped at half that range, so it cannot escape the data - outside it the error curve is flat, and the stopping rule would read that as convergence.

Because every evaluation samples the data afresh, the walk is noisy: it keeps going through stop_patience unproductive steps before giving up, and returns the best point it visited rather than its last.

Parameters:

Name Type Description Default
eval_func EvalFunc

scores a candidate threshold. Called as eval_func(threshold, previous_eval) and returns (mis_classification_ratio, gain), where gain is the improvement over previous_eval - positive when the move helped.

required
starting_point float

threshold to start from, normally the mean of the scores.

required
gradient float

initial step size and direction.

required
num_of_iters int

maximum number of steps before giving up and returning anyway.

required
stop_thresh float

the absolute gain below which a step counts as making no progress.

required
alpha float

per-step decay applied to the gradient, damping the walk as it proceeds.

required
lower_bound float

lowest threshold the walk may reach, normally min(scores).

required
upper_bound float

highest threshold the walk may reach, normally max(scores).

required
stop_patience int

how many consecutive steps must make no progress before the walk gives up. Each evaluation reads a different random subsample, so a single small gain is as likely to be sampling noise as real convergence - stopping on the first one leaves the walk short of the optimum on skewed data.

stop_patience_default

Returns:

Type Description
float

The best threshold visited, meaning the one whose sampled mis-classification

float

ratio was lowest - not wherever the walk happened to stop. The two differ

float

whenever the last step was a step backwards.

Source code in src/thresher/algs/sgd/compute.py
def sgd_solver(
    eval_func: EvalFunc,
    starting_point: float,
    gradient: float,
    num_of_iters: int,
    stop_thresh: float,
    alpha: float,
    lower_bound: float,
    upper_bound: float,
    stop_patience: int = stop_patience_default,
) -> float:
    """Walk the error curve downhill from a starting point, returning the best point seen.

    Each step moves by the current step size and then decays it by `alpha`; only the
    *direction* comes from the measured gain, reversing when a move made things worse.
    The walk is clamped to `[lower_bound, upper_bound]` and each step is capped at half
    that range, so it cannot escape the data - outside it the error curve is flat, and the
    stopping rule would read that as convergence.

    Because every evaluation samples the data afresh, the walk is noisy: it keeps going
    through `stop_patience` unproductive steps before giving up, and returns the best
    point it visited rather than its last.

    Args:
        eval_func: scores a candidate threshold. Called as
            `eval_func(threshold, previous_eval)` and returns
            `(mis_classification_ratio, gain)`, where gain is the improvement over
            `previous_eval` - positive when the move helped.
        starting_point: threshold to start from, normally the mean of the scores.
        gradient: initial step size and direction.
        num_of_iters: maximum number of steps before giving up and returning anyway.
        stop_thresh: the absolute gain below which a step counts as making no progress.
        alpha: per-step decay applied to the gradient, damping the walk as it proceeds.
        lower_bound: lowest threshold the walk may reach, normally `min(scores)`.
        upper_bound: highest threshold the walk may reach, normally `max(scores)`.
        stop_patience: how many consecutive steps must make no progress before the walk
            gives up. Each evaluation reads a different random subsample, so a single
            small gain is as likely to be sampling noise as real convergence - stopping
            on the first one leaves the walk short of the optimum on skewed data.

    Returns:
        The best threshold *visited*, meaning the one whose sampled mis-classification
        ratio was lowest - not wherever the walk happened to stop. The two differ
        whenever the last step was a step backwards.
    """
    previous_eval_point = starting_point

    evaluation = eval_func(previous_eval_point, 0.0)[0]

    # Track the best point seen rather than trusting where the walk ends up. The walk
    # deliberately keeps moving after it stops improving, so the final point is often
    # worse than one already visited.
    best_point, best_eval = previous_eval_point, evaluation
    steps_without_progress = 0

    log.debug("SGD initial run (from point {}). Evaluation: {}", starting_point, evaluation)

    for iter_no in range(num_of_iters):
        previous_eval = evaluation

        log.debug(
            "SGD iteration {}. Previous evaluation: {} for X:{}",
            iter_no,
            previous_eval,
            previous_eval_point,
        )

        # Keep the walk inside the range the scores actually span. A threshold outside it
        # puts every sample in one class, which is never a meaningful answer, and leaves
        # the error curve flat - so the gain goes to exactly 0 and the stop_thresh check
        # below reports convergence on what is really a divergence.
        new_point = min(max(previous_eval_point + gradient, lower_bound), upper_bound)

        log.debug("SGD iteration {}. New point set to: {} because gradient: {}", iter_no, new_point, gradient)

        evaluation, gain = eval_func(new_point, previous_eval)

        log.debug("SGD iteration {}. Evaluation: {} and gain: {}", iter_no, evaluation, gain)

        previous_eval_point = new_point

        if evaluation < best_eval:
            best_point, best_eval = new_point, evaluation

        # The step size follows a fixed decay schedule, and only its *direction* comes
        # from the gain: a move that made things worse turns the walk around.
        #
        # It used to be scaled by the relative gain instead, which compounded: as soon as
        # progress slowed the step shrank, which slowed progress further, until a step so
        # small that two consecutive samples scored identically drove the gain to exactly
        # 0.0 and the step to 0.0 with it. The walk then froze wherever it stood and the
        # check below reported that as convergence. On data whose optimum sits far from
        # the mean it never got close - a threshold that should have been 0.95 came back
        # as 0.56, mis-classifying 39% of samples while reporting success.
        gradient = abs(gradient) * (1.0 - alpha)
        if gain < 0:
            gradient = -gradient

        # Keep a single step proportionate to the data, so the walk cannot be flung from
        # one bound to the other.
        max_step = (upper_bound - lower_bound) / 2.0
        if abs(gradient) > max_step:
            gradient = max_step if gradient > 0 else -max_step

        log.debug("SGD iteration {}. New gradient set to: {}", iter_no, gradient)

        if abs(gain) < stop_thresh:
            steps_without_progress += 1
            if steps_without_progress >= stop_patience:
                break
        else:
            steps_without_progress = 0

    return best_point

Shared helpers

thresher.algs.common.stochastic.stochastic_process

stochastic_process(
    evaluated: float,
    scores: Sequence[float],
    actual_classes: Sequence[int],
    random_factor: float,
    miss_class: bool = True,
) -> float

Evaluate a candidate threshold against a random subsample of the data.

This is the shared basis for the speed of the sgd and genetic solvers on large inputs: neither ever reads the whole dataset to score a candidate.

Parameters:

Name Type Description Default
evaluated float

the candidate threshold to score.

required
scores Sequence[float]

the values being split.

required
actual_classes Sequence[int]

the matching ground-truth classes, as -1 and 1.

required
random_factor float

fraction of the data to sample, between 0 and 1. The sample is floored at one item, so small inputs still produce a usable ratio.

required
miss_class bool

return the mis-classification ratio when True, the accuracy when False.

True

Returns:

Type Description
float

The ratio of mis-classified samples in the subsample - so lower is fitter - or

float

the fraction classified correctly if miss_class is False. Being a subsample,

float

repeated calls with the same threshold return slightly different values.

Source code in src/thresher/algs/common/stochastic.py
def stochastic_process(
    evaluated: float,
    scores: Sequence[float],
    actual_classes: Sequence[int],
    random_factor: float,
    miss_class: bool = True,
) -> float:
    """Evaluate a candidate threshold against a random subsample of the data.

    This is the shared basis for the speed of the `sgd` and `genetic` solvers on large
    inputs: neither ever reads the whole dataset to score a candidate.

    Args:
        evaluated: the candidate threshold to score.
        scores: the values being split.
        actual_classes: the matching ground-truth classes, as -1 and 1.
        random_factor: fraction of the data to sample, between 0 and 1. The sample is
            floored at one item, so small inputs still produce a usable ratio.
        miss_class: return the mis-classification ratio when True, the accuracy when
            False.

    Returns:
        The ratio of mis-classified samples in the subsample - so lower is fitter - or
        the fraction classified correctly if `miss_class` is False. Being a subsample,
        repeated calls with the same threshold return slightly different values.
    """
    population_size = len(scores)

    # int() alone floors to 0 for small inputs (e.g. the 'gen' default of 0.02 does so
    # below 50 rows), which yields an empty sample and a division by zero below.
    sample_size = min(max(1, int(random_factor * population_size)), population_size)

    sample = random.sample(range(population_size), sample_size)
    number_of_correct, number_of_incorrect = 0, 0
    for idx in sample:
        element = scores[idx]
        actual_class = actual_classes[idx]
        pred = 1 if element > evaluated else -1
        if pred == actual_class:
            number_of_correct += 1
        else:
            number_of_incorrect += 1

    if miss_class:
        return number_of_incorrect / (number_of_incorrect + number_of_correct)  # ratio of mis-class
    return number_of_correct / (number_of_incorrect + number_of_correct)

thresher.algs.common.meta_optimizer

Summary statistics used to seed a search before it starts.

calculate_range_mean

calculate_range_mean(
    scores: Sequence[float],
    actual_classes: Sequence[int],
    label: int,
) -> float

Average the scores belonging to one class.

The genetic solver takes the negative-class and positive-class means as the bounds of its initial population, so the search starts around where the boundary should lie.

Parameters:

Name Type Description Default
scores Sequence[float]

the values being split.

required
actual_classes Sequence[int]

the matching ground-truth classes, as -1 and 1.

required
label int

the class to average over, -1 or 1.

required

Returns:

Type Description
float

The mean of the scores whose class equals label. Returns NaN if that class is

float

absent, since numpy averages an empty selection.

Source code in src/thresher/algs/common/meta_optimizer.py
def calculate_range_mean(scores: Sequence[float], actual_classes: Sequence[int], label: int) -> float:
    """Average the scores belonging to one class.

    The genetic solver takes the negative-class and positive-class means as the bounds of
    its initial population, so the search starts around where the boundary should lie.

    Args:
        scores: the values being split.
        actual_classes: the matching ground-truth classes, as -1 and 1.
        label: the class to average over, -1 or 1.

    Returns:
        The mean of the scores whose class equals `label`. Returns NaN if that class is
        absent, since numpy averages an empty selection.
    """
    return float(np.mean([_[0] for _ in zip(scores, actual_classes, strict=False) if _[1] == label]))

get_mean_value_for_class_pd

get_mean_value_for_class_pd(
    label: Any,
    label_column: str,
    data: DataFrame,
    data_column: str,
) -> float

Average one column of a DataFrame over the rows belonging to one class.

The pandas equivalent of calculate_range_mean, for callers holding a frame rather than parallel sequences. Nothing in the package calls this today.

Parameters:

Name Type Description Default
label Any

the class to select on.

required
label_column str

name of the column holding the class labels.

required
data DataFrame

the frame to read.

required
data_column str

name of the column to average.

required

Returns:

Type Description
float

The mean of data_column across the rows where label_column equals label.

Source code in src/thresher/algs/common/meta_optimizer.py
def get_mean_value_for_class_pd(label: Any, label_column: str, data: pd.DataFrame, data_column: str) -> float:
    """Average one column of a DataFrame over the rows belonging to one class.

    The pandas equivalent of `calculate_range_mean`, for callers holding a frame rather
    than parallel sequences. Nothing in the package calls this today.

    Args:
        label: the class to select on.
        label_column: name of the column holding the class labels.
        data: the frame to read.
        data_column: name of the column to average.

    Returns:
        The mean of `data_column` across the rows where `label_column` equals `label`.
    """
    return float(np.mean(data[data[label_column] == label][data_column]))

thresher.algs.common.tools

Small utilities shared by the solvers.

granularity_of_scores

granularity_of_scores(
    scores: Iterable[float],
    number_of_decimal_places: int = 2,
) -> Iterator[float]

Round scores down to a coarser granularity.

Reduces a set of scores to the distinct candidate thresholds worth evaluating. Nothing in the package calls this today - grid search builds its candidates with numpy.linspace instead.

Parameters:

Name Type Description Default
scores Iterable[float]

the values to round.

required
number_of_decimal_places int

how many decimal places to keep.

2

Yields:

Type Description
float

Each score rounded to number_of_decimal_places. Duplicates are not removed.

Source code in src/thresher/algs/common/tools.py
def granularity_of_scores(scores: Iterable[float], number_of_decimal_places: int = 2) -> Iterator[float]:
    """Round scores down to a coarser granularity.

    Reduces a set of scores to the distinct candidate thresholds worth evaluating.
    Nothing in the package calls this today - grid search builds its candidates with
    `numpy.linspace` instead.

    Args:
        scores: the values to round.
        number_of_decimal_places: how many decimal places to keep.

    Yields:
        Each score rounded to `number_of_decimal_places`. Duplicates are not removed.
    """
    for score in scores:
        yield round(score, number_of_decimal_places)