Skip to content

Changelog

All notable changes to this project are documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

Unreleased

0.8.0 - 2026-08-06

Proper logging. Everything this package has to say now goes through loguru at a level per message, one setting decides how much of it is emitted, and tqdm draws the progress bar where it is installed.

Added

  • verbosity, on Thresher, SparkThresher and the command line. It names the lowest level that gets through — 'debug', 'info', 'warning' (the default), 'error' or 'critical' — and can be set three ways, in increasing precedence:
thresher.set_verbosity('info')                 # until it is set again
thresher.Thresher(verbosity='debug')           # this instance's runs
with thresher.verbosity('debug'):              # this block
    ...

The instance setting applies for the duration of each optimize_threshold call and to nothing else, so two Thresher objects in one process can differ — one verbose, one silent, at the same time. Neither logging system offers that on its own: both hold their level globally, which is why the level is checked at the call site here, against a ContextVar. A message below the level is never handed to loguru at all, so it costs nothing to have written it.

An unknown level is a ConfigurationError where it is given — when the Thresher is built, not several seconds into a long run.

  • tqdm as an optional extra, pip install 'thresher-py[progress]'. It draws the progress bar when it is importable and the bar this package carries draws it when it is not. tqdm is given a bar_format shaped like the built-in bar, so installing the extra changes how a run looks and nothing else about it — including that a script watching stderr sees the same percentages either way.

  • -v, -vv, -q, --verbosity and --progress on the command line. -v reports each stage of a run and -vv each step inside it; --verbosity names the level outright; -q is shorthand for --verbosity error, which is what silences the slow-algorithm warning. Where -q and -v disagree, -q wins — it is the flag that takes something away, so it can only have been meant.

  • thresher.propagate_to_logging(), which hands every record to the standard library's logging as well, under the name of the module that emitted it. That is what logging.getLogger('thresher') used to see. Off by default: an application with loguru and logging writing to a console would otherwise print every record twice.

Changed

  • Nothing in the package calls print() any more. Twenty-two calls sat behind if verbose: in six solvers, the dispatcher and the interface, writing to stdout — the stream the command line reserves for its answer, and the one stream a library has no business claiming. tests/test_logging.py walks the package's syntax trees and fails on a print in any of it, so a solver written in the old style is caught rather than reviewed for.

  • Progress bars are drawn on stderr, where the built-in one wrote to stdout before. Nothing had noticed, because the command line had no way to ask for a bar — and this release adds one. tqdm's own default is stderr, so this is also what lets the two backends be swapped without the output moving.

  • No bar is drawn while the log is at debug. Both write to stderr, so together they produce a bar interrupted by log lines and log lines interrupted by a bar; the level wins. This rule existed before, applied by the genetic solver alone, and announced with a printed Warning! Enabling verbosity automatically disables a progress bar. It now applies to every solver and says nothing.

  • The size warning is silenced with thresher.set_verbosity('error'), not logging.getLogger('thresher').setLevel(logging.ERROR). The message says so itself. The old form works again after one call to propagate_to_logging().

  • SparkThresher takes verbosity, and still takes verbose. It has no progress_bar option and gains none: the counting happens on executors, where nobody is watching a terminal, and the driver's share is a sweep over a few thousand bins.

  • print_progress_bar moved to thresher.progress. It is still importable from thresher.utils, where it has been since the first release, and delegates.

Removed

  • verbose is gone from every solver signature. The convention is now run(scores, actual_classes, progress_bar, alg_options), with the same two exceptions as before — linear.compute.run takes no alg_options and run_parallel takes n_jobs, which also loses its verbose. Verbosity is a property of the run rather than an argument threaded through eight functions, which is the point of the change. The verbose option on Thresher is unaffected: it still works, and means verbosity='debug'.

0.7.3 - 2026-08-05

Three defects in how the evolutionary solver turns one generation into the next, and into an answer. On the fixture used to measure them, mean error against exact halved.

Fixed

  • The answer is an agent that was actually measured (#31). Each generation scored its population, selected from it, then bred a replacement — and after the last generation that replacement was returned without ever being scored. One crossover and one mutation therefore reached the answer with no selection in front of them. The fittest agent measured across every generation is returned instead, the same rule the sgd walk already follows in returning its best point rather than its last.

It shows up worst when the mutation is loud. With mutation_chance=1.0:

mutation_factor before now
0.10 0.5078 inside the data
5.0 0.5776 inside the data
50.0 1.3188 — outside a [0, 1] dataset, at 53% accuracy where 90% was available inside the data, within 0.036 of the achievable accuracy

Averaging was also measured against returning the fittest, since the old docstring claimed the mean was deliberate noise reduction: over 40 seeds the mean of the final evaluated survivors gives 0.0151 mean error against 0.0076 for the fittest. Averaging an unconverged population drags the answer toward the middle of its spread, which is a cost rather than a benefit.

  • Mutation moves a threshold either way (#31). The nudge was mutation_factor * random.random(), drawn from [0, mutation_factor) and so never negative — a ratchet rather than a mutation. Measured against exact over 40 seeds of 4,000 rows, the returned threshold sat +0.0061 above the optimum with mutation off, +0.0069 at the default chance and +0.0120 at 0.5: the more often it fired, the further up it pushed. It is now symmetric, and the bias at every rate is within ±0.002.

  • sus_factor is validated instead of being fed to a slice (#31). population_size - sus_factor was used directly as a slice bound, and a slice takes a negative bound to mean "from the far end". Against the default population of 30:

sus_factor before now
30 bare stdlib ValueError: Sample larger than population — not a ThresherError ConfigurationError
35 silently kept 25 survivors — asked to cull 35, culled 5 ConfigurationError
59 silently kept 1 ConfigurationError
61 bare stdlib ValueError again ConfigurationError

population_size, number_of_generations and number_of_iterations are checked with it: each must be a whole number of at least 1, since number_of_iterations=0 made fitness the mean of no samples and population_size=0 left nothing to evolve. All are checked before the simulation starts rather than discovered part-way through it.

Changed

  • A generation is now bred from the previous one's survivors and then scored, rather than scored and then bred. The two orders run the same operations in the same sequence, but only one of them leaves no population unmeasured at the end - which was the whole defect above, and is now a property of the loop's shape rather than a special case in it.
  • Every test now starts from the same global random state, set by an autouse fixture in tests/conftest.py. Four solvers sample through the random module, so a test asserting an outcome from one of them was really asserting something about wherever the generator had been left by the tests before it — they passed or failed on their position in the run. This release changed how many values the genetic solver draws and nothing else about sgrid, and that alone turned an sgrid assertion red.
  • TestScoresOutsideTheUnitInterval runs its sampling solvers at stoch_ratio=1.0. Those tests ask where a solver looks — whether its candidates are laid over the data or over a hardcoded [0, 1] — and sgrid was answering through 5 of 100 rows, or a single row on the three-row inputs, so the assertions were being decided by the draw as much as by the grid. It missed exact accuracy for 43 of 200 starting random states; it now misses for none of them, and the property the test names is what decides it.

0.7.2 - 2026-08-05

numpy and pandas input stops being second-class: it is no longer copied, and no longer comes back with a different type than a list would.

Fixed

  • A numpy.ndarray and a pandas.Series are read where they lie (#30). optimize_threshold kept its argument only when it was a collections.abc.Sequence, and neither of those is one — they have no index or count — so both were copied to lists on the way in. That is the O(n) allocation 0.5.3 removed, reintroduced for the two types this library is built around, and it made hist's bounded memory unreachable through the public API. Optimizing 200,000 rows, peak allocation measured with tracemalloc:
input before now
list 18 KiB 18 KiB
numpy.ndarray 12,520 KiB 17 KiB
pandas.Series 7,832 KiB 18 KiB

What is handed over is now decided by what the solvers use — len(), more than one pass, and integer indexing — rather than by a protocol wider than any of them. A generator or a set still becomes a list, because one pass is not enough.

A Series is handed over as the array underneath it rather than as itself. series[0] is a label lookup, so on a frame that has been filtered — where the surviving rows keep the labels they had — passing it straight through would make the sampling solvers read the wrong row, or none. to_numpy() is a view for the dtypes a score column has, so this costs nothing and the index cannot reach the answer.

Not copying does cost some CPU: iterating an array boxes each element where a list hands over a reference, which measures about 1.3× on hist at 500,000 rows — against roughly 700× less memory, which is the trade worth making for the algorithm chosen for its memory.

  • The result is always a plain float (#30). numpy input leaked an np.float64 out of exact, hist and ls, but not out of grid — so identical data returned a differently-typed answer depending on the algorithm, against an annotation promising float either way. It subclasses float, so nothing broke and no isinstance check could see it; under numpy ≥ 2 it surfaces as np.float64(0.35) wherever the result is printed or embedded. optimize_threshold coerces once, at the single point the package returns an answer.

  • exact, hist and grid asked if not scores:, which raises ValueError for an array of more than one element rather than answering the question. They check the length.

Changed

  • The command line hands the score column to optimize_threshold as pandas holds it, instead of calling .tolist() on it. The frame is already in memory; the list was a second copy of the column at four times the bytes. The label column is still converted, because those values get named back to the user when they are wrong and Found np.int64(0) is not an improvement on Found 0.

Added

  • tests/test_array_inputs.py, which passes an ndarray and a Series — including one whose index has gaps — through every algorithm. Nothing anywhere did before, which is why neither symptom above had surfaced. It asserts the container cannot change the answer, that the answer is a float, and that allocation stays flat, this last one through Thresher rather than against a solver directly: the existing bounded-memory test calls histogram.run with lists, so it passed throughout with the interface copying both arguments in full.

0.7.1 - 2026-08-05

Four defects that all shared a shape: a plausible number returned from input that should never have produced one.

Fixed

  • hist returns a threshold that achieves the accuracy it reports (#20). The binning floors, so a score sitting exactly on a bin edge belongs to the bin above it; the prediction rule is score > threshold, which sends a score sitting exactly on the threshold to the class below it. Returning the edge itself made the two disagree for precisely the samples on that edge, so the sweep reported a count the threshold does not achieve — and could prefer a worse edge to a better one.

On the reported case it returned a threshold scoring 6/10 while claiming 8/10, with 8/10 available from another edge. Across 200 seeds of two-decimal scores at 100 bins, 174 lost more than half a point of accuracy against exact; none do now.

Stepping one representable value below the edge is close but not sufficient: that arithmetic and the (score - lowest) / span * bins used to bin are not inverses in floating point, so a score can bin above an edge and still compare below it. The boundary is now found with the binning function itself, which cannot disagree with the counting by construction. Measured over 5,000 randomised shapes, the sweep never reports more than the returned threshold delivers; before this change 9 of them did.

The same reconstruction problem reached the topmost split, which expressed "classify everything as negative" as lowest + span. That is not always the largest score: for scores rounded to a few decimal places - the ordinary case - 0.065 + (0.997 - 0.065) is 0.9969999999999999, so the largest samples were classified positive while the counting had them negative. sweep_bins now takes lowest and highest rather than lowest and a span, deriving the span from the pair, so the range has one authoritative definition and nothing is rebuilt from it.

The binning is likewise now one function, bin_index, used by both the counting pass and the threshold search, so they cannot drift apart again. Over 20,000 randomised shapes the reported count is exactly what the returned threshold delivers. The Spark path shares sweep_bins and is fixed with it.

  • A score that is not a number is refused (#23). Only the labels were ever checked. A NaN reached the solvers and each failed its own way: exact sorted it into place and handed it back as the answer — a "threshold" that classifies everything negative, since every comparison against NaN is false — while hist raised a bare ValueError out of its bin arithmetic. The command line printed nan and exited 0, which reads as success. validate_scores now runs beside the existing guards, so all seven algorithms refuse it identically with UndefinedScoresError. None is refused with it; infinities are not, since a threshold can be placed relative to them.

  • The Spark interface refuses the rows the in-memory path refuses (#21, #24). The class counts come from equality against the two declared labels, so a row matching neither — a null, a third class, a typo — was absent from both and landed in the negative count by omission. Six rows labelled 0/1 returned 0.25; adding three rows labelled 2 returned 0.99, where the in-memory path raises. Null and NaN scores went the same way: Spark's least skips nulls, so a row with no score was filed in the top bin, and NaN sorts above everything, so it became the maximum and collapsed the sweep.

A frame whose labels match neither class was also reported as SingleClassError naming a class that does not appear in the data at all; it is now UnexpectedLabelsError, which names the values that do.

All the counts come from the first aggregation, so the checks cost no extra pass; only naming the offending labels reads the frame again, and only on the way out.

Added

  • UndefinedScoresError, an InvalidInputError and so a ValueError, carrying .count.

Changed

  • histogram.compute.sweep_bins takes highest where it took span, for the reason above. It is a solver internal rather than part of the documented API, and the only callers are hist itself and the Spark interface.

0.7.0 - 2026-08-04

Added

  • A multiprocessing backend. backend='mp' spreads the counting over the CPU cores of the machine you are already on:
from thresher import Thresher


def main():
    Thresher(backend="mp").optimize_threshold(scores, actual_classes)

if __name__ == "__main__":   # required; see below
    main()

It is shaped exactly like the Ray backend, and shares its guarantee: the data is sharded once, each worker counts its own shard with the same plain functions from backends/base.py, and the driver adds the partials together. Addition is order-independent, so the answer is identical to a local run rather than merely close - asserted for exact, ls and grid, across several worker counts, and on data full of duplicates and ties.

Unlike ray it needs nothing installed, multiprocessing being in the standard library. That makes it the parallel option on macOS x86_64, where Ray publishes no wheel at all.

The same three algorithms distribute as on Ray: sgrid and gen draw a fresh random subsample per evaluation and sgd is a sequential walk, so those three still run in-process whatever backend is asked for.

MultiprocessingBackend(num_workers=...) configures the process count; -1 means every processor bar one. Below min_rows_per_shard (2,000 by default) the work stays in this process, since handing it to another costs more than the counting saves.

  • ParallelBootstrapError, raised when worker processes cannot start. It is a RuntimeError, which is what BrokenProcessPool - the failure it replaces - already was.

Fixed

  • A parallel run in an unguarded script fails in a second instead of hanging forever (#22). Worker processes re-import __main__ on spawn platforms, so a script that parallelises at module level made every worker re-run it and start workers of its own. multiprocessing.Pool.map then waited on children that would never report: no error, no result, no end - the run had to be killed. The backend uses concurrent.futures.ProcessPoolExecutor, whose BrokenProcessPool is translated into a ParallelBootstrapError carrying the __main__-guard fix.

  • n_jobs values that name no process count are refused (#22). 0 and anything below -1 raise ConfigurationError; they used to print a message and carry on with one process, so the warning changed nothing. Asking for more processors than the machine has is clamped to what it has, rather than opening that many - n_jobs=9999 no longer tries for 9,999 processes.

  • Parallelising linear search no longer changes its answer (#22). n_jobs now runs the ordinary search on the mp backend rather than through a second implementation. The old parallel path evaluated the raw scores as thresholds where the sequential path evaluates the midpoints between them, so the two returned different - equally valid - answers for the same data. They now agree exactly.

Changed

  • An explicitly chosen backend takes precedence over n_jobs, which is the older spelling of the same request; the two would otherwise contend for the same cores. This was already the behaviour, and is now tested.
  • linear.compute.process_batch is removed. It existed only to be pickled into the pool it no longer uses; backends.base.tally_chunk does that counting for every backend.

0.6.4 - 2026-08-04

Three defects in the approximate solvers, all of which returned a plausible number while being wrong or slow rather than failing.

Fixed

  • grid and sgrid lay their candidates across the data instead of over [0, 1] (#25). The grid was hardcoded to the unit interval on the assumption that scores are probabilities. Given anything else - logits, margins, distances - every candidate fell outside the data, so the answer was whichever edge scored better: chance accuracy, returned in silence. On separable scores spanning [-5, -2] both returned 0.0 at 50% accuracy where every other solver reached 100%; they now do too.

The grid spans [min(scores), max(scores)] at the same 10**places + 1 resolution, so the candidates are spent on the range the data occupies, plus one point below the minimum that keeps "classify everything as positive" expressible - the one split the old [0, 1] grid could only reach by accident. Ties still go to the leftmost candidate, so a threshold inside the data is never given up to break one.

For scores that really are in [0, 1] the resolution now covers the observed range rather than the whole interval, which changes results very slightly: across the benchmark, grid moved from 99.82%/99.98%/100.00% to 99.83%/99.99%/99.92% of the exact optimum on the separable, overlapping and imbalanced sets.

  • The sgd step size scales with the data (#26). The first step was a constant 0.05 and only ever decays, so the walk's total travel was bounded at roughly 4.3 score units however far away the boundary was. On scores spanning thousands it stopped short every run - deterministically, unlike this solver's known sampling noise. The step is now a fraction of the score range, exposed as step_ratio (default 0.05), so the same data multiplied by 1,000 returns exactly 1,000× the threshold. Probability-shaped scores span about 1, so their behaviour is unchanged.

  • sgrid with reshuffle=True samples by index (#27). It built the full list of (score, class) pairs before sampling from it, so every candidate cost a pass over the whole input however small stoch_ratio was - O(c·n) against the documented O(c·r·n), and slower than the exhaustive grid it exists to approximate. At 200,000 rows it went from 5.30 s to 0.80 s, against exhaustive grid's 1.26 s. Its memory column in the benchmark drops from O(n) to O(r·n) for the same reason.

Added

  • step_ratio for sgd, above. Documented in the README and docs/algorithms.md, which the parity test added in 0.6.3 insisted on before this would build.

0.6.3 - 2026-08-03

Removed

  • optimized_start from the genetic algorithm's documented parameters (#34). The solver stopped reading it long ago - the class-mean seeding it once toggled is now unconditional - but it stayed in the README, which is the only parameter documentation. Passing it did nothing, and nothing said so.

Added

  • Each solver now declares the algorithm_params keys it reads, as a known_params frozenset beside the defaults that define them, and Thresher(...) rejects anything else with a ConfigurationError naming the offending key and listing the accepted ones. A mistyped stoch_ration used to fall back to the default in silence, so the run continued with the value the caller believed they had replaced.
>>> Thresher(algorithm='sgd', algorithm_params={'stoch_ration': 0.5})
ConfigurationError: Unknown algorithm_params key(s) for sgd: 'stoch_ration'.
It reads: alpha, num_of_iters, stoch_ratio, stop_patience, stop_thresh. ...

set_algorithm re-validates, since parameters valid for one algorithm need not be valid for the next, and SparkThresher applies the same check - a key ignored there would have been ignored across a whole cluster run. exact accepts none at all, and says so: being exact, it has no accuracy to trade for speed.

  • tests/test_internals.py::TestDocumentedParameters compares the README's parameter lists against those sets in both directions, so neither can drift again. It is what would have caught optimized_start in the first place.

Fixed

  • algorithm_params is checked to be a mapping, rather than failing later and less clearly.
  • Parameters belonging to the stochastic grid search - stoch_ratio and reshuffle - are no longer accepted for the exhaustive grid, which shares the implementation but never reads them.
  • The README's algorithm_params examples, the -p/--param help text and examples/sample_parallel.py all named a parameter without naming the algorithm that reads it, so they demonstrated a silent no-op under the default algorithm. The example in particular claimed to run linear search across several processes and ran the exact sweep in one (#35).
  • The command line reports these as -p/--param keys rather than naming the algorithm_params constructor option, which a terminal user cannot act on.

0.6.2 - 2026-08-03

Fixed

  • Thresher(...) rejects option names it does not recognise, with a ConfigurationError listing the valid ones (#33). A mistyped name - Thresher(algoritm='gen') - was previously merged into the options dict and never read, so the run silently used the defaults the caller believed they had changed.
  • A malformed labels option is rejected when the object is built, rather than discarded: anything that is not a two-item list or tuple raises LabelMappingError. Previously a non-iterable value was ignored in silence - and the eventual error told the caller to declare the very option they had already declared - while a one-item mapping died later as a bare IndexError inside map_labels. labels=None stays accepted and means no mapping.
  • A non-string algorithm raises UnknownAlgorithmError as the constructor documents, instead of escaping as a bare AttributeError from .lower().
  • NotIterableError names the argument that is actually at fault, and carries it as an attribute; it used to blame "scores" even when actual_classes was the one that could not be iterated.

0.6.1 - 2026-08-03

Added

  • thresher.__version__ - the installed package version, importable at last (#40). It is read from the distribution metadata, so pyproject.toml stays the single source of truth, and it always matches what thresher --version prints - the command was already versioned; the library now admits to one too.

Fixed

  • The [Unreleased] compare link at the foot of this file still pointed at v0.5.3; it now moves forward with each release again.

0.6.0 - 2026-08-03

Added

  • An Apache Spark interface - thresher.spark.SparkThresher, which takes a DataFrame and two column names rather than two in-memory sequences, and never collects the rows.

The Ray backend distributes the ordinary API, which still requires the data to be in memory first. That is the wrong shape for data living in HDFS, S3 or a Delta table: collecting a billion rows onto one machine to sort them is what having a cluster is supposed to avoid.

from thresher.spark import SparkThresher

SparkThresher().optimize_threshold(df, score_col="probability", label_col="label")

The problem reduces to a map-reduce, and this takes that reduction literally. Spark runs one groupBy and a pair of sums - a shuffle of counts, not of rows - and returns a summary sized by the resolution rather than by the row count. The driver then sweeps that summary:

a billion rows  ->  [ Spark: group and count ]  ->  ~1,024 rows  ->  [ driver: sweep ]
  • hist and exact are the two algorithms offered, for the same reason only some algorithms distribute on Ray: their work is an aggregation. hist is the default here and groups by bin index, so its summary is no_of_bins rows however large the input; exact groups by distinct score and warns when it is collecting more than a million of them. ls, grid, sgrid, gen and sgd raise ConfigurationError rather than quietly running on a sample or on the driver - ls is quadratic in candidates, and the other three draw their own random subsamples, so distributing them would change the answer and not merely where it was computed.

  • pyspark>=3.4 as an optional extra, pip install 'thresher-py[spark]'. It is not a runtime dependency: thresher.spark can be imported without it, and reports its absence as BackendDependencyError when a SparkThresher is built.

Changed

  • The deciding half of both supported algorithms is now a reusable function over counts - sweep_bins in algs/histogram/compute.py and sweep_class_counts in algs/exact/compute.py. The Spark path calls those same functions rather than reimplementing the sweep, which is what makes the results identical instead of merely close. tests/test_spark.py asserts a distributed run returns the same float as the in-memory run, including on data full of ties, and that repartitioning to 1, 3 or 8 partitions does not move it.

  • CI pins a Temurin 17 JDK for the test matrix, since PySpark 4.x needs Java 17 or newer and the runner image's JDK is not something to rely on by accident.

Fixed

  • The documentation home page still advertised six algorithms; the histogram sweep added in 0.5.3 made it seven.

0.5.3 - 2026-07-27

Added

  • hist, a histogram sweep - a non-exact estimator whose memory does not follow the input. The score range is divided into a fixed number of bins, the classes falling into each are counted in one pass, and the bins are swept with the same running-total argument the exact sweep uses over distinct scores. Nothing is sorted and no row is read twice.
hist exact
100,000 rows 19 KB 12 MB
1,000,000 rows 49 KB 107 MB

The cost is resolution: a threshold can only sit on a bin edge, so the answer is off by at most one bin width. That makes it the one approximation here with a bounded error rather than a statistical one - it does not sample, so it returns the same answer every run. At the default 1,024 bins it captures 99.98% of the achievable accuracy, and no_of_bins trades resolution against memory directly.

Where grid also evaluates a fixed set of candidates, it rescans every row for each one (O(c·n)); this reads each row once whatever the resolution, at O(n + k).

Changed

  • optimize_threshold no longer copies inputs that are already sequences. It always built its own lists, which costs memory proportional to the input and defeated the point of an algorithm whose own allocation is flat - hist would still have paid for two full copies to reach it. A Sequence can be measured and iterated more than once, which is all the solvers need; anything else is consumed into a list as before. End-to-end memory for hist on a million rows fell from 15.7 MB to 49 KB.

  • The Documentation link in the package metadata points back at the GitHub Pages build, and the README now links to Read the Docs. PyPI can verify a URL it can tie to the publishing repository - which, under Trusted Publishing, includes that repository's GitHub Pages domain - so this returns the link to the "Verified details" section of the PyPI project page. Read the Docs, being a third-party domain, cannot be verified. The README keeps the Read the Docs link because that is the build with a copy of every release and a version switcher.

0.5.2 - 2026-07-27

Changed

  • The Documentation link in the package metadata now points at Read the Docs rather than the GitHub Pages build, so the link shown on the PyPI project page leads to the host that keeps a built copy of every release. It points at stable, which tracks the newest tag, so a reader arriving from a release on PyPI gets that release's documentation rather than whatever has been merged since.

Both sites are published and carry the same content; they are built from the same mkdocs.yml and the same docs dependency group. The README continues to link to the GitHub Pages build.

Package metadata is written when the distribution is built, so this only takes effect from this release onwards - the link on older versions still points at GitHub Pages.

0.5.1 - 2026-07-27

Added

  • A documentation site, published at oskar-j.github.io/thresher. Built with MkDocs and Material, with the API reference generated by mkdocstrings from the Google-style docstrings the package already carried - so the reference cannot drift from the source, because it is the source.

Guides for getting started (including using it with scikit-learn), the algorithms and how they compare, the command line, the Ray backend and the exception hierarchy, plus a generated API reference for every public class and function, and the changelog.

  • .github/workflows/docs.yml, which builds the site on every pull request and deploys it when main moves. The build runs with --strict, so a dead cross-reference or a page missing from the navigation fails rather than quietly shipping a broken site.

  • .readthedocs.yaml, so Read the Docs works as an alternative host. GitHub Pages needs no third-party account and is what the badge points at; importing the repository at readthedocs.org will pick this file up and build the same site from the same dependency group.

  • A docs dependency group, kept out of dev because the toolchain is large and only the docs build needs it, and make docs / make docs-serve.

  • A documentation badge, and a link to the site from the top of the README.

0.5.0 - 2026-07-27

Removed

  • The oracle, as announced in 0.4.0. It chose an algorithm from the size of the input, because the only exact algorithm was O(n²) and stopped being affordable, so accuracy had to be traded against volume. exact removed that trade-off in 0.4.0 by being exact at every size and cheaper than the approximations, at which point the oracle had nothing left to decide and had been returning exact unconditionally.

run_oracle() is gone, and the algorithm is settled when a Thresher is built rather than per call.

Nothing is required of you. Callers on the default already had exact, and algorithm='auto' - along with 'default' and 'default_heuristics' - still works as a synonym for the default.

  • 'auto' is no longer an entry in available_algorithms, so get_supported_algorithms() returns six real algorithms rather than five plus the oracle. Thresher().get_current_algorithm() now reports exact instead of auto, which is what actually runs.

  • thresher.oracle is now thresher.dispatch. Keeping a module named for a mechanism it no longer contains would repeat the naming mismatch exceptions.py had before 0.4.5. It holds run_computations, and is still the only module that imports the solvers.

Added

  • A warning when an algorithm is asked to handle more data than it is comfortable with. data_vol_thresh was vestigial once the oracle stopped routing on it; it is now filled in for every algorithm and drives a logging.warning:
WARNING thresher.dispatch: Linear search is likely to be slow on 12,000 rows - it is
usually comfortable up to about 10,000. The 'exact' algorithm is exact and O(n log n)...
Algorithm Comfortable up to Algorithm Comfortable up to
exact 10,000,000 grid 1,000,000
sgrid 10,000,000 gen 100,000
sgd 2,000,000 ls 10,000

The figures come from the timings in examples/benchmark.py, at roughly where a run passes ten seconds on one laptop. They are guidance rather than limits - a faster machine moves them all up - so crossing one warns and continues rather than refusing. It goes through logging rather than warnings precisely so it can be silenced the ordinary way: logging.getLogger("thresher").setLevel(logging.ERROR).

  • A Memory column in the README's algorithm comparison, derived from the implementations. It shows that exact is O(d) in distinct scores rather than rows, that grid is the only algorithm whose memory does not grow with the input, and that sgrid allocates O(n) despite reading only a fraction of the data - it builds the full paired list before sampling, so the subsampling buys time but not memory.

0.4.5 - 2026-07-26

Added

  • An exception hierarchy. exceptions.py held only message strings, so everything was raised as a builtin and callers had to catch ValueError - which also swallows failures from numpy, pandas or their own code. Every error now derives from ThresherError:
ThresherError
├── ConfigurationError      a name that does not exist          (ValueError)
│   ├── UnknownAlgorithmError
│   └── UnknownBackendError
├── InvalidInputError       the data cannot be optimized over   (ValueError)
│   ├── EmptyInputError, LengthMismatchError, MissingLabelsError,
│   └── UnexpectedLabelsError, SingleClassError, InsufficientDataError
├── LabelMappingError       the `labels` option cannot map      (TypeError)
├── NotIterableError        arguments are not iterable          (AttributeError)
├── BackendDependencyError  an optional dependency is missing   (ImportError)
├── AlgorithmNotWiredError  a bug in this package               (NotImplementedError)
└── ShardMergeError         a bug in this package               (ValueError)

Every class also inherits the builtin it was previously raised as, shown on the right. That is what keeps this an addition rather than a breaking change: code catching ValueError, TypeError, AttributeError, ImportError or NotImplementedError behaves exactly as before - including this package's own command line, which catches ValueError and ImportError. There are tests for both directions.

  • Errors carry their detail as attributes rather than only in prose: LengthMismatchError.score_count and .class_count, MissingLabelsError.count, UnknownAlgorithmError.name and .available, UnknownBackendError.name and .available, UnexpectedLabelsError.unexpected, SingleClassError.only.

Changed

  • NotIterableError inherits AttributeError rather than TypeError, which would fit the failure better. Earlier versions raised AttributeError here, and changing it would break existing except clauses for no practical gain.
  • The message templates remain importable module constants. They define the wording, the classes format them, and they were importable before the classes existed.

0.4.4 - 2026-07-26

Clears the outstanding entries from CLAUDE.md's known issues, ahead of 0.5.0.

Fixed

  • Mismatched input lengths now raise instead of being silently truncated. The solvers pair scores and actual_classes with zip, which stops at the shorter sequence, so six scores against four classes quietly discarded two scores and returned a threshold computed from the rest - a wrong answer rather than a partial one, with nothing in the result to hint at it. optimize_threshold now raises ValueError naming both counts.

This is a behaviour change: code that was relying on the truncation, knowingly or not, will now see an exception. That is the point - it was previously getting an answer derived from part of its data.

  • Missing labels are now named as missing. A blank cell in a CSV arrives as NaN, which was reported as an unrecognised label and pointed at the labels option - advice that fits a differently-encoded class, not an absent one, and that sends people looking for a mapping which cannot exist. The message now says how many values are missing and that those rows need filling in or dropping. Found while checking how the length-mismatch error reads from the command line.

Added

  • stoch_ratio for sgd (default 0.05, unchanged), the one parameter the algorithm did not expose while gen and sgrid both did. It is the documented lever against sgd's weak spot - when one class is rare, a small subsample carries little information about where the boundary lies. On 2,000 rows with 5% positives, raising it from 0.05 to 0.5 took the mean error from 0.0394 to 0.0035 and the worst case across 20 seeds from 0.302 to 0.013, at the cost of reading ten times as much data per step.

Changed

  • The README's "Sample usage" now imports the class directly, matching the opening example since 0.3.2.

0.4.3 - 2026-07-26

Added

  • Test coverage is now measured and enforced. Branch coverage is on, the floor is 90%, and CI applies it on every supported Python version - the test jobs are required checks on main, so a pull request that drops below the floor cannot be merged. Coverage stands at 95%, and each job uploads its coverage.xml as an artefact.
  • make cov runs the tests with coverage and the same threshold CI uses.
  • A coverage badge. It states the enforced floor rather than a measured number, because that is what can be guaranteed: CI makes the claim true on every merge, whereas a hardcoded percentage would drift and a third-party service would read "unknown" until someone linked the repository to it.
  • Tests for the parts the end-to-end suite reached around rather than through: the shared helpers (granularity_of_scores, calculate_range_mean, get_mean_value_for_class_pd, pairwise, get_or_default, map_labels, print_progress_bar, stochastic_process in both of its modes), process_batch - which normally runs inside a worker process where neither assertions nor coverage reach it - every verbose and progress_bar reporting path, the CLI's value coercion and failure paths, and the dispatch guard that fires when the algorithm registry and run_computations disagree.

One behaviour was pinned down in the process: calculate_range_mean emits two numpy RuntimeWarnings and returns NaN when a class is absent. That is unreachable through optimize_threshold, which rejects single-class input first, but reachable by calling the helper directly.

0.4.2 - 2026-07-26

Added

  • A Ray backend, so the same computation can be spread over a Ray cluster instead of running in one process. Install with pip install 'thresher-py[ray]' and select with Thresher(backend="ray") or thresher --backend ray. The default is unchanged and Ray is not required.

The work is a map-reduce: the data is sharded once into Ray's object store, each worker counts its own shard, and the driver adds the partial counts together. exact, ls and grid are distributed this way. sgrid and gen are not - each of their evaluations reads its own random subsample, so sharding would change which samples are drawn and therefore the result - and sgd is a sequential walk with nothing to parallelise. All three still run under backend='ray', just in-process.

A backend changes where the work happens, never the answer. Both map steps are plain functions shared verbatim: the Ray backend ships them to workers rather than reimplementing them, and the reduce steps are addition, which is order-independent. Tests assert the two backends return identical results, not merely close ones, including on data full of duplicates and ties.

  • backend option on Thresher, and --backend on the CLI. Accepts a name or a configured backend instance, so sharding can be tuned with RayBackend(num_shards=16). An unknown name, or a missing Ray, is reported when the object is built rather than partway through a run.

Changed

  • exact, ls and grid now score their candidates through the backend rather than in a local loop. Results are unchanged - the existing brute-force and parity tests cover this
  • but with progress_bar=True the bar for ls and grid now brackets the work instead of advancing through it, since candidates are scored in one batch.
  • For ls, a non-local backend takes precedence over the n_jobs multiprocessing option, which predates backends and would only contend with a cluster for cores.

0.4.1 - 2026-07-26

Fixed

  • exact now considers the "classify everything as positive" split, and is therefore optimal over every split a threshold can induce rather than only those inside the data. Expressing it needs a threshold below the smallest score, which nothing could previously return, so on data where scores and classes run contrary to each other the best answer was unreachable. Given [0.1, 0.2, 0.3] labelled [1, 1, -1] the sweep returned 0.15, classifying 1 of 3 correctly, where 2 of 3 was available. The threshold used is math.nextafter(min(scores), -inf) - the largest float that qualifies, so the answer stays as close to the data as the representation allows - and it is taken only on a strict improvement, never to break a tie. Across 4,000 randomised inputs the sweep now matches a brute force over both edge splits every time; the new split is chosen in about 10% of them.

This is the only case where a returned threshold can fall outside the range of the input. The other algorithms cannot express either edge split and are unchanged.

Added

  • A Makefile with install, check, test, fmt, types, bench and build targets. make install installs the git pre-commit hook alongside the dependencies, and make check runs git add --intent-to-add . before the hooks.

That last detail is the point of it. pre-commit run --all-files only sees files git already tracks, so a newly created file is skipped in silence while the run reports success - and CI, which checks out the committed tree, then fails on exactly what the green local run missed. That caught out two releases in a row. make check closes it, and the installed hook closes it again at commit time.

0.4.0 - 2026-07-26

Added

  • exact, an exact algorithm in O(n log n), and the new default. It returns the best threshold that exists, not an approximation, and does so faster than any of the approximations it replaces.

Linear search is O(n²) because it recomputes the whole confusion matrix for each of the n-1 candidate thresholds. That work is almost all redundant: moving the threshold past a single sample changes the number of correct predictions by exactly one, in a direction fixed by that sample's class. Sorting once and sweeping while carrying running counts therefore costs O(n log n), dominated by the sort:

correct(k) = (negatives among the first k) + (positives among the remaining n - k)

This is the standard exact splitter for a decision-stump threshold, and the same linear scan used to generate an ROC curve - Fawcett, "An introduction to ROC analysis" (Pattern Recognition Letters, 2006), and Google's decision forests documentation, which gives the same O(n log n) bound "because of the sorting of the feature values". Runs of equal scores are indivisible and are stepped over whole, the tie-handling point Fawcett makes.

Measured against linear search on the same inputs:

rows exact ls speedup
1,000 0.6 ms 65 ms 104×
4,000 2.2 ms 957 ms 431×
16,000 12 ms 16,536 ms 1,358×

exact doubles in cost when the input doubles; ls quadruples, as O(n²) requires, so the gap keeps widening.

It is also marginally more accurate than linear search. ls only considers midpoints between adjacent scores, so it cannot express the "classify everything as negative" split; the sweep reaches it at max(scores). Over 3,000 randomised inputs with duplicates, ties and inverted labels, exact matched the brute-force optimum every time and beat ls in 287 of them.

Deprecated

  • The oracle mechanism, to be removed in 0.5.0. It existed to choose between algorithms trading accuracy against input size, and exact settled that question, so there is nothing left to delegate. exact becomes the plain default in 0.5.0. Code using the default is unaffected - it already resolves to exact - and algorithm='auto' will keep working as an alias for the default.

Changed

  • The oracle now always selects exact. It previously routed on input size - linear search below 1,000 rows, grid search below 50,000, stochastic gradient descent above that - because the only exact algorithm was O(n²) and stopped being affordable. That trade-off no longer exists. Callers who relied on the default choosing a particular algorithm for a particular size will see a different, and better, one; select by name to pin the old behaviour.
  • Algorithm.data_vol_thresh is now advisory. It records where each of the older algorithms stops being a sensible manual choice, and is no longer read for routing.

0.3.2 - 2026-07-26

Added

  • A thresher command-line interface, installed with the package. It reads a delimited file (or stdin) holding one row per sample and prints the optimal threshold:
$ thresher scores.csv
0.35

Only the bare number goes to stdout, so the result pipes into another command without anything to strip out; progress and errors go to stderr. Flags cover the usual shape mismatches - --labels for classes that are not -1 and 1, --sep and --no-header, --score-column and --label-column to select by name or index, -a to pick the algorithm and -p key=value to pass its parameters. --list-algorithms prints the algorithms with their aliases.

Library exceptions are rewritten for a terminal audience: a mistyped algorithm points at thresher --list-algorithms rather than at get_supported_algorithms(), and unmapped labels point at --labels 0,1 rather than at Thresher(labels=(0, 1)). Exit codes follow convention - 2 for a usage mistake, 1 when the data cannot be optimized. - click as a runtime dependency, for the above. It is a small pure-Python package next to the existing numpy and pandas requirements, so the command works from a plain pip install thresher-py rather than needing an extra. - Two more README badges: the supported Python versions and the licence. Both read from PyPI metadata, so neither can drift out of date the way a hardcoded badge would.

Changed

  • The README's opening example imports the class directly - from thresher import Thresher
  • rather than reaching through the module.

0.3.1 - 2026-07-25

Fixed

  • The sgd walk no longer stalls short of the optimum. Its step size was scaled by the relative gain of each move, which compounded: as progress slowed the step shrank, which slowed progress further, until a step so small that two consecutive subsamples 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 stopping rule reported that as convergence. The step now follows a fixed decay schedule and takes only its direction from the gain.

The effect is largest where the optimum sits far from the mean of the scores, which is where the walk begins. On separable data with the boundary at 0.85, sgd returned about 0.56 and mis-classified 29% of samples while reporting success. Mean error against the true optimum, over 8 seeds:

data before after
boundary at 0.70 0.1343 0.0014
boundary at 0.85 0.2910 0.0015
boundary at 0.95 0.3929 0.0074
separable 0.0305 0.0037
noisy, 15% overlap 0.1762 0.1676
  • sgd now returns the best threshold it visited rather than whichever one it stopped on. The walk continues past unproductive steps, so its final position was often worse than a point it had already passed through.
  • Dividing by the previous evaluation is gone with the old step rule, so the guard added in 0.2.2 against a zero-valued divisor is no longer needed. It used to return immediately when a subsample happened to be classified perfectly, abandoning the search on the strength of one lucky sample.

Added

  • stop_patience (default 3) for sgd: how many consecutive unproductive steps end the walk. Every evaluation reads a different random subsample, so a single small gain is as likely to be noise as convergence, and stopping on the first one left the walk short. Worst-case error over 20 seeds on a heavily skewed 2,000-row input fell from 0.452 to 0.302, and mean error from 0.287 to 0.039.

  • examples/benchmark.py, which measures every algorithm against the exact optimum and prints the comparison table in the README. The reference optimum is computed by sweeping the sorted scores, independently of the algorithms being measured, and is checked against brute force.

Changed

  • Rewrote the README opening. The optical-illusion image and its caption are replaced with an explanation of what the project is for - that a predict_proba cut-off is a decision most pipelines leave at 0.5 by default rather than by measurement.
  • Added a table of contents to the README, and an "Algorithm scores" section comparing the five algorithms on accuracy, runtime and complexity. Implemented algorithms is now a top-level heading, so the sections nest properly under it.

0.3.0 - 2026-07-25

No behavioural change to the public API: Thresher, its options and its results are the same as in 0.2.3. This release is about the shape of the project.

Changed

  • Adopted the src/ layout: the package now lives in src/thresher/. Tests therefore run against the installed package rather than whatever happens to be on sys.path, so a packaging mistake fails the suite instead of hiding behind the working directory.
  • Moved the tests out of the distribution to a top-level tests/. They were previously shipped inside the package as thresher.tests and excluded from the wheel by hand.
  • Fixtures are now located relative to the test file. The suite ran only from inside thresher/tests, while examples/sample.py required the repository root - the two were mutually exclusive. Both now run from anywhere.
  • Converted the suite from unittest to pytest, using fixtures and conftest.py. Parametrisation expands the same coverage from 26 cases to 65.
  • Moved the README illustration to docs/assets/.
  • The evolutionary algorithm's agents are a dataclass rather than a dict. samples and fitness are now separate fields, which makes the 0.2.1 fitness bug - where one key held both and the aggregate overwrote the samples - impossible to express.
  • map_labels() raises TypeError for a non-list/tuple mapping instead of asserting, so the check survives python -O. run_oracle() likewise raises TypeError rather than asserting when a routing threshold is missing from the registry, and the grid solver was restructured so it no longer needs a type-narrowing assertion at all. No assert statements remain in src/.

Added

  • Type annotations throughout, checked by mypy --strict, plus a py.typed marker so the annotations are visible to consumers of the published package.
  • .pre-commit-config.yaml running ruff, ruff-format, mypy and file-hygiene hooks.
  • Ruff and mypy configuration in pyproject.toml.
  • A docs/ directory with a placeholder index.md.
  • Docstrings on every module, class and function in src/, in the Google style already used by Thresher, documenting arguments, return values and the exceptions raised. They record behaviour that is not evident from the signatures - that run_parallel evaluates the scores themselves as thresholds while run evaluates the midpoints between them, that grid search always spans [0, 1] regardless of the input range, that the genetic solver returns the population mean rather than its fittest agent, and that an unrecognised algorithm_params key is silently ignored.
  • A pre-commit CI job. Pull requests are now gated on both it and the test matrix.
  • examples/sample_data.py, replacing the loader that used to live in the test package.

Fixed

  • The examples now guard their entry point with if __name__ == "__main__":. examples/sample_parallel.py asks for multiprocessing, and on platforms whose start method is spawn - macOS and Windows - every worker re-imports the script. Without the guard each worker re-ran the example and spawned workers of its own, so the example hung instead of failing. It worked only on Linux, where the start method is fork.

0.2.3 - 2026-07-25

Fixed

  • An unknown algorithm name now raises ValueError naming the value and listing the valid algorithms, instead of a bare StopIteration with no message. retrieve_by_alias() ended in a next() call with no default, so Thresher(algorithm='typo') surfaced an exception that read as an internal bug rather than a rejected argument.
  • Invalid labels now raise ValueError explaining the problem, instead of a message-less AssertionError. Single-class input says both classes are required; labels outside (-1, 1) point at the labels constructor option; empty input is named as such. The previous check was an assert, which python -O strips entirely — malformed input then reached the solvers rather than being rejected, and there is now a test that runs under -O to keep that from returning.

Changed

  • set_algorithm() now raises ValueError for an unknown name. It previously printed a message and returned successfully with the old algorithm still selected, so callers were told a switch had happened when it had not. Code that relied on that silent no-op will now see an exception.

Added

  • ThresherInputValidationTest, covering each of the above plus the alias lookups that must keep working.

0.2.2 - 2026-07-24

Fixed

  • sgd no longer walks outside the range of the scores it was given, and converges much closer to the optimum. Three things compounded: the gradient's sign was flipped a second time whenever a move made the error worse, cancelling the correction that would have reversed the walk; the step size was unbounded, so a large relative gain could fling the point across the whole range; and once outside the data the error curve is flat, so the gain hit exactly 0 and the stopping rule reported convergence on a divergence. The walk is now clamped to [min(scores), max(scores)], the double sign flip is gone, and the step is capped at half the data range. On separable data, mean error against linear search falls from 4.0790 to 0.0321 at 5,000 rows and from 0.8883 to 0.0077 at 2,000; on noisy data it roughly halves at every size tested.
  • sgd no longer raises ZeroDivisionError when a stochastic evaluation mis-classifies nothing. The gradient update divided by that evaluation; it now stops and returns the point instead, since a zero mis-classification ratio cannot be improved on. This was reachable on cleanly separable data at most data volumes, and mattered most because the oracle selects sgd for inputs above 50,000 rows.
  • The stochastic solvers no longer raise ZeroDivisionError on small inputs. Sample sizes were computed as int(ratio * N), which floors to 0 — breaking gen below 50 rows and sgrid below 20. Sample sizes are now clamped to at least 1 and at most the input size.
  • get_current_algorithm() no longer raises TypeError. It used with on an Algorithm namedtuple, so it could never have worked.
  • n_jobs=-1 for linear search no longer raises TypeError. The documented "use all processors except one" behaviour produced a negative chunk size, making pool.map return None entries. The chunk size is now derived from the resolved process count.
  • The multiprocessing pool used by linear search is now closed, resolving a ResourceWarning.

Added

  • ThresherCrashRegressionTest and ThresherResultRangeTest, covering each of the above. These paths — explicitly selected algorithms, small inputs, and separable data — had no coverage previously. The range test asserts every algorithm returns a threshold within the span of its input scores.
  • Badges in README.md for the released version, build status, downloads and stars.
  • The README illustration is now vendored at assets/optical-illusion.png instead of being hotlinked from a third-party site, so it cannot break or change underneath the project. It is W. E. Hill's "My Wife and My Mother-in-Law" (1915), public domain.

0.2.1 - 2026-07-24

Fixed

  • Evolutionary algorithm: fitness is now computed from the accumulated per-iteration mis-classification samples instead of from the agent's own threshold value. The previous code called np.mean() on a scalar, so it discarded every fitness sample it had gathered and selected agents by threshold value rather than by how well they performed. Results are now materially closer to the true optimum, and the intermittent test_data_case_alt2 failure (~10% of runs) is resolved.

Added

  • Automated publishing to PyPI, using Trusted Publishing (OIDC), triggered after a GitHub Release is created.

0.2.0 - 2026-07-24

Added

  • pyproject.toml (PEP 621) as the single source of project metadata and dependencies.
  • uv.lock — a fully resolved, cross-platform dependency lockfile for reproducible environments.
  • Support for Python 3.13 and 3.14. The test suite is verified against 3.10 through 3.14.
  • CHANGELOG.md (this file).
  • CLAUDE.md with build/test commands and an architecture overview.
  • Automated GitHub Release publishing when a version bump lands on main.

Changed

  • Packaging migrated from setup.py to uv, using the hatchling build backend.
  • Minimum supported Python raised to 3.10 (3.9 reached end-of-life in October 2025).
  • openpyxl is now declared as a dev dependency. It is required to read the .xlsx test fixtures and was previously an undeclared, missing requirement.
  • Release tags now follow the standard v0.2.0 form rather than the previous v_01_2 style.

Removed

  • setup.py, setup.cfg, and requirements.txt, all superseded by pyproject.toml.
  • xlrd dependency. It was declared as a runtime requirement but was only ever used to read test fixtures — which are excluded from the distribution — and xlrd 2.x cannot read .xlsx files at all.

0.1.2 - 2020-10-14

Added

  • Grid search algorithm, with granularity controlled by no_of_decimal_places.
  • Stochastic grid search algorithm, adding the stoch_ratio and reshuffle parameters.
  • algorithm_params constructor argument for passing per-algorithm settings.
  • Custom label mapping via the labels argument, for inputs not using (-1, 1).
  • Multiprocessing for linear search through the n_jobs parameter.
  • Performance evaluation notebooks under examples/performance_test/, with a 10^6-row anonymized dataset.

Changed

  • Reworked the oracle's algorithm selection (fixes #1).
  • Moved example scripts into a separate examples/ directory.

0.1.1 - 2020-10-12

Added

  • meta_optimizer.py, providing per-class mean helpers used to seed the evolutionary algorithm's initial population range.

Changed

  • Algorithmic improvement to the genetic (gen) method.
  • Distribution renamed to thresher-py because of a PyPI name conflict.

0.1.0 - 2020-10-11

Added

  • Initial release of Thresher.optimize_threshold().
  • Linear search algorithm.
  • Naive 2-dimensional stochastic gradient descent algorithm.
  • Evolutionary (genetic) algorithm.