What it actually does
Guess a linear boundary. Take one example; if you got it right, change nothing. If you got it wrong, push the boundary toward the answer by adding that example to the weight vector. Repeat. That is the whole algorithm, and it has a remarkable property: if a boundary that separates the classes exists, this finds one in a bounded number of mistakes, and the bound depends only on how wide the gap is — not on how many examples there are or how many features. It also has a remarkable failure: if no such boundary exists, it never stops, and it never tells you why.
- The classes are linearly separable in the given feature space — the convergence guarantee holds only here, and real data almost never satisfies it
- Features are scaled comparably, since the mistake bound is expressed through the data radius
- Labels are ±1, which the sign-based update requires
- Any separating hyperplane is acceptable, because the objective cannot distinguish between them — if the widest one is wanted, this is the wrong algorithm
Loss function
Add up how wrong you are, counting only the examples you got wrong. A correctly classified point contributes exactly zero no matter how comfortably it is classified, which distinguishes this from the hinge loss the SVM uses — the hinge charges for being correct but close, and this does not. That difference is the whole gap between the two: any separating hyperplane drives this objective to zero, so the perceptron stops at whichever one it happens to reach, and which one that is depends on the order the data arrived in.
- the currently misclassified examples — the only ones that contribute anything at all
- label in {-1, +1}; the sign convention is what makes the product above a correctness indicator
- the signed distance to the boundary, up to scale — the raw score, and not a probability
- positive exactly when the example is wrong, and proportional to how badly
How it is minimized
Online mistake-driven updates — stochastic gradient descent on the perceptron criterion, one example at a time
- learning rate, which for the classical perceptron only rescales the weights and does not change which boundary is found
- radius of the data — the largest example norm
- the margin: how much room the best separating hyperplane has. The bound is entirely about geometry
- total updates ever made, independent of the number of examples and of the dimension
The update is stochastic gradient descent on the criterion above, and the gradient of a misclassified term is exactly minus y times x — so the algorithm predates SGD by decades and is a special case of it. What makes it worth studying is the convergence theorem: on separable data the number of mistakes is bounded by the squared ratio of the data radius to the margin, with no dependence on the sample size or the dimension at all. That is a genuinely strong statement, and the appearance of the margin in it is the seed of everything the SVM later does — if the mistake bound improves with margin, maximizing the margin is worth doing on purpose. The classical algorithm does not; it stops at whatever separator it finds first.
learning rate
For the classical perceptron it only rescales the weights and does not change the decision boundary, which is unusual and worth knowing (1.0; there is genuinely nothing to tune here)
epochs
Passes over the data. On separable data it terminates on its own; on non-separable data this is the only thing that stops it (5 to 50, with averaging)
averaging
Return the average of all intermediate weight vectors rather than the last. Almost free, and it turns an unstable estimator into a reliable one
shuffling
Order matters, because the algorithm is order-dependent by construction. Reshuffling each epoch is standard
margin threshold
Update when the margin is below a positive threshold rather than below zero, which is the step from the perceptron toward the SVM
On linearly separable data it converges in finitely many mistakes and the bound is tight. On non-separable data — which is nearly all real data — it never converges: the weight vector cycles indefinitely, and there is no signal in the algorithm that anything is wrong. That is the defining failure, and the usual mitigation is a fixed epoch budget plus averaging. Two further properties are worth stating. The solution is order-dependent: shuffle the data and you get a different boundary, because any separator satisfies the objective equally. And the final weight vector is the one produced by the last update, which may have come from a single outlier — averaging over the whole run fixes this at almost no cost, which is why the averaged perceptron rather than the plain one is what anybody actually uses.
O(nnz) per example, where nnz is the number of non-zero features — the algorithm only touches features that are present, which is why it stayed competitive on sparse high-dimensional text long after better models existed. Memory is O(d) for the weights and O(1) beyond that, with no need to store the data at all. Training is a single pass per epoch and is inherently sequential, since each update depends on the boundary the previous updates produced.
How, where, and why it is used
Time-Series Forecasting
Not applicableIt is a binary classifier with no representation of order or of a continuous target; applying it means discarding the temporal structure and predicting a label, at which point the forecasting problem is gone.
Anomaly Detection
Not applicableA supervised classifier needing labelled examples of both classes, with no notion of density or outlyingness — an anomaly it has never seen is simply an input on one side of the boundary.
Optimization
AdaptedThe convergence theorem is the reason this entry earns a place under optimization: it is the first and simplest proof that an online, mistake-driven procedure terminates, and the bound is purely geometric — the squared ratio of data radius to margin, with no dependence on sample size or dimension. Reading that proof is how the connection between margin and generalization first becomes visible.
- The reference example of an online learning algorithm with a mistake bound, which is a different guarantee from a convergence rate
- The origin of stochastic gradient descent, decades before it was called that
- The bound that motivates margin maximization, and therefore the SVM — if mistakes fall with margin, widening it deliberately is the obvious next move
It is worth studying because the guarantee has an unusual shape: not "the objective decreases at this rate" but "you will be wrong at most this many times, ever", which is the natural statement for a streaming setting and generalizes into the regret bounds of modern online learning. The counterweight is equally instructive — the guarantee holds only under separability, and outside it the algorithm has no behaviour to speak of. A theorem with a precondition that real data almost never satisfies is a useful thing to have met early.
- Scale features so the data radius R is not dominated by one column, since the mistake bound is expressed in terms of it
- Add the bias as a constant feature rather than tracking it separately, which keeps the geometry of the bound intact
Count mistakes rather than measuring final accuracy: the mistake curve against the theoretical bound is the diagnostic, and a run whose mistakes never stop accumulating is telling you the data is not separable.
- Applying the bound to non-separable data, where it says nothing at all
- Reading the learning rate as a tuning parameter when for the classical algorithm it only rescales the weights
- Treating a mistake bound as a generalization bound; they are different statements about different things
Natural Language
AdaptedThe averaged and structured perceptron were the standard trainers for part-of-speech tagging, chunking and dependency parsing before neural methods. The structured variant generalizes the update from a single label to a whole output structure: decode the best sequence or tree under the current weights, and if it differs from the gold structure, add the gold features and subtract the predicted ones. The update is a difference of feature vectors and nothing else — no probabilities, no partition function.
- Part-of-speech tagging and chunking with the averaged perceptron, the standard approach through the 2000s
- Transition-based dependency parsing, where the perceptron trains the action classifier
- Any structured prediction task where a decoder exists but a normalized probability is unaffordable
- Online learning over very high-dimensional sparse features, where only present features are ever touched
Text is the case that suits it: enormously high-dimensional, extremely sparse, and often close enough to separable that the algorithm behaves. The structured version’s real advantage is that it needs only a decoder and never a partition function, which is what made it trainable on problems where a conditional random field was not. It is superseded now — neural taggers and parsers are better, and the features here are hand-designed — but the training rule survives in structured settings where normalization is intractable.
- Sparse indicator features, with only the present ones touched per update — this is where the O(nnz) cost profile pays
- Use the averaged perceptron rather than the plain one; the accuracy gap on tagging tasks is large and the cost is close to zero
- Feature hashing when the vocabulary is unbounded, which keeps the weight vector fixed-size on a stream
Token accuracy or labelled attachment score on a held-out split, and the mistake rate per epoch as the training diagnostic — a rate that plateaus above zero says the data is not separable under these features, which is information about the features rather than the trainer.
- Skipping averaging, which leaves the model at whatever the final update produced — frequently a single odd sentence
- Assuming the structured update needs a probability; it needs a decoder, and conflating the two leads to an unnecessary and expensive model
- Ordering effects when sentences are not shuffled, since the algorithm is order-dependent by construction
What it costs to run
A pass over the data per epoch, touching only non-zero features, with no matrix operations at all. The cheapest trainable model in this section by a wide margin, and it needs no data resident — examples can stream past once and be discarded.
A sparse dot product and a sign: nanoseconds, with a model that is one weight per feature. Small enough to embed anywhere, and updateable in place while serving, which is a property almost nothing else here has.
Genuinely continuous. The update rule is defined on one example, so a deployed perceptron can learn from each new labelled instance as it arrives with no refit — the original motivation for the algorithm and still its clearest advantage.
- Track the mistake rate per epoch or per window; a rate that stops falling means the data is not separable under the current features
- Watch the weight norm, which grows without bound on non-separable data and is the visible signature of the cycling failure
- Compare the averaged weights against the current ones — a large divergence means recent updates are unrepresentative
- Monitor the fraction of predictions near zero score, since the perceptron gives no confidence and that band is where it is guessing
- Labels must be -1 and +1, not 0 and 1; passing 0/1 does not error and silently optimizes something else
- The output is a sign, not a probability, and the raw score has no calibrated meaning — anything thresholding it as confidence is unfounded
- Use the averaged weights in production and the running weights for training; deploying the final weight vector means deploying whatever the last update did
- On non-separable data the weight norm grows every epoch, so a fixed epoch budget is a required stopping rule rather than a convenience
- Feature indices are part of the model: regenerating the vocabulary separately reindexes every weight
Pros and cons, in context
A mistake bound that depends only on geometry
At most the squared ratio of data radius to margin, independent of sample size and dimension. A genuinely strong guarantee, and the appearance of the margin in it is the observation the SVM is built on.
Truly online: O(1) memory beyond the weights, and no stored data
Learns from one example and discards it, which makes it deployable on an unbounded stream and updateable while serving. Almost nothing else in this section can be updated in place.
Touches only non-zero features
O(nnz) per example rather than O(d) is why it stayed competitive on sparse high-dimensional text long after better models existed. Irrelevant on dense features, where it has no advantage at all.
Averaging turns it from unstable into reliable at almost no cost
The averaged perceptron closes most of the accuracy gap to far more expensive methods on structured tasks. A rare case where a one-line change is most of the value.
Never converges on non-separable data, and does not say so
The weight vector cycles indefinitely with no signal that anything is wrong. Since nearly all real data is non-separable, the guarantee that motivates the algorithm rarely applies to it.
Cannot represent XOR, or anything not linearly separable
The limitation Minsky and Papert proved, and the reason the field stalled and then built multilayer networks. It is the whole motivation for the next entry rather than a footnote to this one.
No probabilities and no margin
The output is a sign, and any separating boundary satisfies the objective, so which one you get depends on data order. Logistic regression gives probabilities and the SVM gives the widest margin — this gives neither.
Order-dependent and unstable
Shuffle the data and get a different model. Averaging mitigates it substantially and does not remove it, and reproducibility requires pinning the shuffle.
Intuitive, idiomatic, optimized
The same algorithm in three languages, each progressing through Kent Beck's three stages. Every optimization names the lever it pulls and the trade-off it accepts — cost profiles are illustrative, not measured benchmarks.
The math, transcribed literally. No dependencies.
"""The perceptron - mistake-driven updates, transcribed.
If the prediction is right, change nothing. If it is wrong, push the boundary
toward the answer by adding the example to the weights. That is the whole
algorithm, and it is also stochastic gradient descent on the criterion above -
decades before it was called that.
"""
def predict(weights, bias, x):
"""Sign of the score. NOT a probability - the magnitude means nothing."""
score = bias
for j in range(len(x)):
score += weights[j] * x[j]
return 1 if score > 0 else -1
def fit(X, y, learning_rate=1.0, epochs=20):
"""y must be in {-1, +1}: the update is built on the sign convention."""
d = len(X[0])
weights = [0.0] * d
bias = 0.0
for epoch in range(epochs):
mistakes = 0
for i in range(len(X)):
# margin = y_i (w . x_i + b); at most zero means misclassified
score = bias
for j in range(d):
score += weights[j] * X[i][j]
margin = y[i] * score
if margin <= 0.0:
mistakes += 1
for j in range(d):
weights[j] += learning_rate * y[i] * X[i][j]
bias += learning_rate * y[i]
# On separable data the mistakes stop and the algorithm terminates.
# On non-separable data they never do - the weight vector cycles
# forever, and nothing here reports that. The epoch budget is the
# only thing that stops it.
if mistakes == 0:
return weights, bias, epoch + 1
return weights, bias, epochs
def mistake_bound(X, margin):
"""(R / gamma)^2 - the theoretical bound, purely geometric.
R is the largest example norm and gamma the margin of the best separating
hyperplane. Note what is absent: the number of examples, and the number of
features. Neither appears.
"""
radius = 0.0
for row in X:
norm = sum(value * value for value in row) ** 0.5
radius = max(radius, norm)
return (radius / margin) ** 2O(n*d) per epoch in interpreter loops, touching every feature of every example whether or not it is non-zero.
How this language actually wants it written.
"""Averaged perceptron - typed, sparse, with the lazy averaging trick."""
from dataclasses import dataclass, field
import numpy as np
from numpy.typing import NDArray
Vector = NDArray[np.float64]
@dataclass
class AveragedPerceptron:
"""Returns the AVERAGE of every intermediate weight vector, not the last.
The plain perceptron ends wherever its final update left it, which may
have come from a single odd example. Averaging over the whole run closes
most of the accuracy gap to far more expensive methods, and the naive
implementation costs O(d) per example. The trick below makes it O(nnz)
per MISTAKE instead, which is what makes averaging free enough to be the
default rather than an option.
"""
n_features: int
weights: Vector = field(init=False)
_totals: Vector = field(init=False) # accumulated weights * time
_stamps: NDArray = field(init=False) # when each weight last changed
_step: int = field(default=0, init=False)
def __post_init__(self) -> None:
if self.n_features < 1:
raise ValueError(f"n_features must be at least 1, got {self.n_features}")
self.weights = np.zeros(self.n_features, dtype=np.float64)
self._totals = np.zeros(self.n_features, dtype=np.float64)
self._stamps = np.zeros(self.n_features, dtype=np.int64)
def score(self, indices: NDArray, values: Vector) -> float:
"""Sparse dot product: only present features are touched at all."""
return float(self.weights[indices] @ values)
def update(self, indices: NDArray, values: Vector, label: int) -> None:
"""One mistake-driven update, with averaging maintained lazily.
Instead of adding the whole weight vector to a running total on every
example, each coordinate carries the timestep it last changed. When it
changes again, the intervening span is settled in one multiply - so
the accumulated total stays exact while only touched coordinates are
ever visited.
"""
self._step += 1
span = self._step - self._stamps[indices]
self._totals[indices] += span * self.weights[indices]
self._stamps[indices] = self._step
self.weights[indices] += label * values
def averaged(self) -> Vector:
"""Settle every coordinate to the current step, then average."""
span = self._step - self._stamps
return (self._totals + span * self.weights) / max(self._step, 1)
def fit(
rows: list[tuple[NDArray, Vector]],
labels: NDArray,
n_features: int,
epochs: int = 10,
seed: int = 0,
) -> tuple[Vector, list[int]]:
"""Train on sparse rows. Raises ValueError on malformed input."""
if len(rows) != labels.size:
raise ValueError(f"{len(rows)} rows but {labels.size} labels")
if not np.isin(labels, (-1, 1)).all():
raise ValueError("labels must be -1 or +1; 0/1 silently optimizes something else")
model = AveragedPerceptron(n_features=n_features)
rng = np.random.default_rng(seed)
order = np.arange(len(rows))
history: list[int] = []
for _ in range(epochs):
# Order matters: the algorithm is order-dependent by construction, so
# reshuffling each epoch is part of the method rather than hygiene.
rng.shuffle(order)
mistakes = 0
for index in order:
indices, values = rows[index]
if labels[index] * model.score(indices, values) <= 0.0:
model.update(indices, values, int(labels[index]))
mistakes += 1
history.append(mistakes)
# A mistake count that stops falling means the data is not separable
# under these features - which is information about the features.
if mistakes == 0:
break
return model.averaged(), historyWhat changed: Two changes carry the value, and neither is about NumPy. Averaging replaces the final weight vector, which is the one-line change that turns an unstable estimator into a reliable one — the plain perceptron ends wherever its last update left it, frequently a single odd example. Implementing it naively costs O(d) per example, so the lazy trick keeps a per-coordinate timestamp and settles the intervening span only when a coordinate is touched, making averaging O(nnz) per mistake and therefore free enough to be the default. The representation also becomes sparse, so only present features are ever visited — which is the cost profile that kept this algorithm competitive on text.
O(nnz) per example and O(nnz) per mistake for averaging, rather than O(d) for both.
Faster, and the justification for each change.
"""Perceptron - one contiguous CSR block, in-place scatter updates."""
import numpy as np
from numpy.typing import NDArray
Vector = NDArray[np.float64]
class CsrPerceptron:
"""Sparse rows in one flat block, not a list of arrays.
The previous stage held each row as its own pair of arrays, which means a
pointer chase and a bounds check per example. A CSR layout puts every
index and value in one contiguous buffer, so a row is a slice and the
whole dataset streams through cache in order.
The algorithm itself stays sequential - each update depends on the
boundary the previous updates produced, and no rewrite changes that. What
is available is making every individual update as cheap as possible.
"""
def __init__(self, n_features: int) -> None:
# A single dtype throughout: float64 weights, int32 indices. Mixed
# dtypes force a conversion on every gather.
self._weights = np.zeros(n_features, dtype=np.float64)
self._totals = np.zeros(n_features, dtype=np.float64)
self._stamps = np.zeros(n_features, dtype=np.int64)
self._step = 0
# Scratch reused by every update rather than allocated per row.
self._scratch = np.empty(n_features, dtype=np.float64)
def fit(
self,
indptr: NDArray,
indices: NDArray,
values: Vector,
labels: NDArray,
epochs: int = 10,
seed: int = 0,
) -> Vector:
rng = np.random.default_rng(seed)
order = np.arange(indptr.size - 1)
for _ in range(epochs):
rng.shuffle(order)
mistakes = 0
for row in order:
start, stop = indptr[row], indptr[row + 1]
cols = indices[start:stop]
vals = values[start:stop]
# Gather-and-dot over present features only. For a row with
# 30 non-zeros out of a million features, this is 30 loads.
if labels[row] * (self._weights[cols] @ vals) > 0.0:
continue
self._step += 1
mistakes += 1
# Lazy averaging: settle only the coordinates being touched.
span = self._step - self._stamps[cols]
# In place, through a view rather than a temporary: at high
# update rates these allocations dominate the arithmetic.
np.multiply(span, self._weights[cols], out=self._scratch[: cols.size])
np.add.at(self._totals, cols, self._scratch[: cols.size])
self._stamps[cols] = self._step
np.add.at(self._weights, cols, labels[row] * vals)
if mistakes == 0:
break
span = self._step - self._stamps
return (self._totals + span * self._weights) / max(self._step, 1)What changed: The algorithm is irreducibly sequential — each update depends on the boundary the previous ones produced — so there is no parallelism to find, and the honest optimization is making each individual update as cheap as possible. Sparse rows move from a list of separate arrays into one contiguous CSR block, so a row is a slice rather than a pointer chase and the dataset streams through cache in order. The averaging scratch is allocated once and written through in place, because at high update rates the per-update temporaries dominate the actual arithmetic. Index and weight dtypes are pinned so no gather pays a conversion.
Ensure contiguous memory layout and a single dtype
One CSR block with int32 indices and float64 values means a row is a contiguous slice and every gather reads without a dtype conversion.
Trade-off: The matrix must be built up front in CSR form, which rules out the genuinely streaming use the perceptron is otherwise ideal for — an unbounded stream cannot be pre-assembled.
Pre-allocate output arrays and use in-place operations
The averaging scratch is allocated once at full width and sliced per update, so a run with millions of updates allocates once rather than per update.
Trade-off: The scratch is shared mutable state, so the object is not reentrant, and slicing it to the row width relies on rows never exceeding the feature count — true here and unchecked.
Vectorize to NumPy/BLAS (@, np.dot, einsum)
The score is a gathered dot product over present features only, which for a 30-non-zero row in a million-feature space is 30 loads rather than a million.
Trade-off: A gather-and-dot is memory-bound and irregular, so it gets none of the throughput a dense BLAS call would — the win is asymptotic, from touching less, not from faster arithmetic.
O(nnz) per example with one contiguous pass over the dataset per epoch. Illustrative, not a measured benchmark.