Simulated Annealing
General Principle
Simulated annealing is a probabilistic optimization technique inspired by the metallurgical process of annealing, where metals are heated and slowly cooled to reduce defects and reach a low-energy state.
The key idea is to allow the algorithm to:
- Explore the solution space through random movements (like thermal fluctuations)
- Exploit promising regions by moving toward better solutions
- Gradually reduce randomness over time (cooling), converging to a good solution
This balance between exploration and exploitation helps escape local minima—a critical advantage over greedy algorithms like Lloyd’s method.
Mechanisms in K-means Context
In kmeanssa-ng, cluster centers are dynamic entities that move through
the metric space using two complementary mechanisms:
Brownian Motion (Exploration)
Centers perform random walks in the metric space, characterized by:
- Random direction selection
- Distance traveled proportional to \(\sqrt{\Delta t}\) (diffusion scaling)
- Allows escape from local minima through stochastic exploration
For a center \(c\) and time parameter \(\Delta t\):
Drift (Exploitation)
Centers are pulled toward the observations assigned to their cluster:
- Each center drifts toward a randomly selected point in its cluster
- Distance traveled: proportion \(\alpha\) of the geodesic distance
- Reduces cluster energy by moving centers closer to their observations
For a center \(c\), target observation \(x\), and drift proportion \(\alpha \in [0,1]\):
(where the notation is geometric; on graphs this means moving along the geodesic path)
Temperature Schedule
The “temperature” controls the balance between exploration and
exploitation over time. kmeanssa-ng advances an annealing clock with
an inhomogeneous Poisson process of intensity
\(\lambda(t) = \lambda_0 (1 + t)\) (the schedule of the companion paper).
The \(n\)-th observation is processed at clock time
where \(E_i \sim \text{Exp}(\lambda_0)\) are the inter-arrival intervals.
Key properties:
- Temperature decreases over time (cooling)
- Controlled by parameter \(\lambda\) (intensity)
- Stochastic schedule adds robustness
Running the Algorithm
The algorithm runs through a single method, run, which interleaves
exploration and exploitation as it sweeps once through the (shuffled)
observations. For each observation \(x_i\) it:
- Advances the annealing clock and performs Brownian motion on every center: \(c_j \leftarrow \text{move}(c_j, \Delta t)\)
- Finds the nearest center \(c^*\) to \(x_i\) and drifts it toward the observation
run takes an initialization strategy (how the centers
start—e.g. KMeansPlusPlus), a robustification strategy (how the
final centers are chosen from the tail of the
sweep—e.g. MinimizeEnergy), and robust_prop, the fraction of the
final observations used for that selection:
from kmeanssa_ng import generate_sbm, SimulatedAnnealing, KMeansPlusPlus, MinimizeEnergy
from kmeanssa_ng.quantum_graph.sampling import UniformNodeSampling
graph = generate_sbm(sizes=[25, 25], p=[[0.8, 0.1], [0.1, 0.8]], random_state=0)
points = graph.sample_points(150, strategy=UniformNodeSampling(random_state=0))
sa = SimulatedAnnealing(points, k=2, beta0=0.5, step_size=0.05, random_state=0)
centers = sa.run(KMeansPlusPlus(), MinimizeEnergy(), robust_prop=0.1)
print(f"Found {len(centers)} cluster centers")
Found 2 cluster centers
Passing an integer random_state (as above) makes a run fully
reproducible.
Two robustification strategies are built in, trading quality for speed.
MinimizeEnergy (the default) keeps the lowest-energy state in the
collection window — the higher-quality choice, but it recomputes the
energy repeatedly. On a quantum graph, MostFrequentNode instead
returns the most frequently visited node, which is faster and places
each center exactly on a node, at the cost of some selection accuracy.
Prefer MinimizeEnergy unless the selection cost dominates your
runtime.
Algorithm Parameters
The behavior of the simulated annealing algorithm is controlled by three main parameters. Understanding these parameters is crucial for achieving good clustering results.
lambda0: Observation-Clock Intensity
Controls the rate at which observations arrive, and therefore how much Brownian exploration happens between them.
Mathematical role: lambda0 is the scale of the inhomogeneous
Poisson clock, of intensity \(\lambda(t) = \lambda_0 (1 + t)\); it sets
the pace of the annealing schedule. It does not scale the Brownian
steps — each micro-step has standard deviation \(\sqrt{\Delta t}\) (the
step_size), independent of lambda0.
Practical effects:
- Higher values (\(\lambda_0 \in [1.5, 3.0]\)):
- Observations arrive faster, so the same number of observations spans a shorter annealing horizon
- Less Brownian exploration between observations
- Higher risk of getting trapped in a local minimum
- Lower values (\(\lambda_0 \in [0.3, 0.8]\)):
- A longer horizon, hence more Brownian exploration between observations
- Better escape from local minima, at the cost of more computation
- Default: \(\lambda_0 = 1.0\) provides a good balance for most use cases.
Choosing it for your problem: the annealing horizon grows like
\(\sqrt{n_\text{obs} / \lambda_0}\), so with more observations you can
afford a larger lambda0 (each observation already refines the
centres). On larger graphs or with more clusters, keep lambda0 at or
below \(1.0\): the longer horizon gives the centres the exploration they
need to separate distant modes rather than collapsing onto the nearest
one.
beta0: Drift Intensity
Controls how strongly centers are pulled toward observations.
Mathematical role: The drift proportion at time \(t\) is computed as: $\(\alpha(t) = \min(h \cdot \beta_0 \cdot \log(1 + t), 1)\)$ where \(h\) is the time interval. This controls the strength of attraction toward the nearest observation.
Practical effects:
- Higher values (\(\beta_0 \in [2.0, 5.0]\)):
- Stronger drift toward observations
- Faster convergence
- More exploitation of currently good positions
- Risk of premature convergence
- Lower values (\(\beta_0 \in [0.3, 0.8]\)):
- Weaker drift
- More time for exploration
- Slower convergence
- Better for difficult optimization landscapes
- Default: \(\beta_0 \in [1.0, 2.0]\) works well for most problems
Interaction with lambda0: lambda0 sets how long the annealing
runs (the horizon), while beta0 sets how hard each observation pulls
its nearest centre. A strong drift (beta0) on a short horizon (large
lambda0) converges fast but can lock the centres in before they have
explored — the classic premature-convergence failure. If clusters come
out merged or a centre is stranded, lower beta0 or lambda0
(lengthening the horizon) rather than raising them.
step_size: Time Discretization
Controls the temporal resolution of the stochastic process simulation.
Mathematical role: Euler discretization step \(\Delta t\) for solving the stochastic differential equation (SDE). Smaller values provide more accurate simulation of the continuous process.
Practical effects:
- Smaller values (\(\Delta t \in [0.001, 0.01]\)):
- More accurate simulation of the theoretical process
- Slower computation (more steps needed)
- Better numerical stability
- Larger values (\(\Delta t \in [0.05, 0.1]\)):
- Faster computation
- Less accurate approximation
- Risk of numerical instability
- Default: \(\Delta t = 0.01\) provides a good accuracy/speed tradeoff
Rule of thumb: Choose step_size much smaller than the
characteristic time scale of your Poisson process (approximately
\(1/\lambda_0\)).
Checking it is small enough: a Brownian micro-step moves a centre by
about \(\sqrt{\Delta t}\). If that is comparable to the spacing between
clusters, a centre can jump across a cluster boundary in a single step
and the discretisation is too coarse. Halve step_size until the
clustering stops changing; as a guide, keep \(\sqrt{\Delta t}\) well below
the smallest inter-cluster distance in your space.
Choosing Parameter Combinations
Different parameter combinations suit different optimization scenarios:
Quick Convergence (when you trust your initialization)
sa = SimulatedAnnealing(
points, k=5,
lambda0=0.5, # Reduced exploration
beta0=3.0, # Strong drift
step_size=0.01
)
Thorough Search (complex problems, many local minima)
sa = SimulatedAnnealing(
points, k=5,
lambda0=2.0, # More exploration
beta0=1.0, # Gentler drift
step_size=0.01
)
Balanced (default, works well in most cases)
sa = SimulatedAnnealing(
points, k=5,
lambda0=1.0, # Moderate exploration
beta0=2.0, # Moderate drift
step_size=0.01
)
Theoretical Foundation
For the derivation of the annealing dynamics and its convergence analysis, see the companion paper: C. Brécheteau, I. Gavra, N. Klutchnikoff, Online k-means Clustering on Metric Graphs and Geodesic Spaces (in preparation).
Monitoring Convergence
Pass record_energy=True to run to record the \(k\)-means energy after
every observation (plus the initial state). The trajectory is exposed as
energy_history, with the matching annealing times as time_history.
It is the raw exploration path of the centers—it rises and falls as
the anneal explores—and the robustification returns its lowest-energy
configuration. Plotting it is the usual way to see how the anneal
progresses:
sa = SimulatedAnnealing(points, k=2, beta0=0.5, step_size=0.05, random_state=0)
sa.run(KMeansPlusPlus(), MinimizeEnergy(), robust_prop=0.1, record_energy=True)
history = sa.energy_history # energy after each observation
print(f"recorded {len(history)} energies; lowest reached = {history.min():.3f}")
recorded 151 energies; lowest reached = 1.224
Recording does not affect the result and is off by default, so the energy is only recomputed when you ask for it.
Robustification
To improve stability, kmeanssa-ng uses robustification: instead of
returning the final centers, it averages results from the last \(p\%\) of
iterations (default: 10%). This reduces sensitivity to late-iteration
fluctuations.