In short: every model has the same shape. Fit takes an f32 [n, d] tensor (and
f32 targets or i32 labels), copies it into a double linalg::Matrix through one shared helper,
and returns a Status; Predict (and PredictProba where it makes sense)
converts the answer back to f32 or i32 tensors. Options are plain structs with scikit-learn's defaults where
there is one. The solvers are the textbook ones, written out: Cholesky normal equations or gradient descent,
Newton with a line search, Lloyd's algorithm after k-means++ seeding, an SVD, counting, a CART sweep,
bootstrap forests, LightGBM-style histogram boosting and LIBSVM-style SMO. Randomness comes from seeded
Philox generators and parallel loops from the thread pool, so results do not depend
on the thread count. Nothing here trains through autograd, and the inference stack does not use it.
Each header links its concept page on /ml-models: linear regression and regularization, logistic regression, nearest neighbors, clustering, PCA, Bayes' rule, decision trees, random forests, gradient boosting and support vector machines.
The shared shape
The helpers in internal are the only shared code
(common.h L12–31). Their checks are RML_CHECKs,
so a wrong dtype or shape is a programmer error that aborts; data problems a caller cannot rule out in
advance (singular systems, too few classes, k > n, non-convergence) come back as a
Status.
| Helper | Signature | What it does | Source |
|---|---|---|---|
Features | linalg::Matrix(const Tensor& x), and (x, num_features) | Checks f32, rank 2, at least one row, and copies into a Matrix. The two-argument form, used by every Predict, also checks the column count against the fitted data. | common.cc L16–26 |
Targets | vector<double>(const Tensor& y, int64_t n) | Rank-1 f32 of length n. | L28–34 |
ClassLabels | vector<int32_t>(const Tensor& y, int64_t n) | Rank-1 i32 of length n, every label ≥ 0. | L36–44 |
NumClasses | int(const vector<int32_t>&) | 1 + the largest label, so classes are always 0..K−1 even when some are missing. | L46–48 |
VectorTensor, LabelTensor | Tensor(const vector<double>&), Tensor(const vector<int32_t>&) | Back to f32 [n] and i32 [n]. | L50–61 |
ArgMaxRows | Tensor(const linalg::Matrix&) | Index of each row's largest entry, first one on ties, which is why every classifier's Predict breaks ties toward the smallest label. | L63–73 |
The models at a glance
| Header | Classes | Fit | Defaults |
|---|---|---|---|
linear_regression.h | LinearRegression | Centered normal equations by Cholesky, or gradient descent | l2 0, intercept on, normal equations; GD lr 0.1, 10,000 iterations, tol 1e-7 (L30–39) |
logistic_regression.h | LogisticRegression | Newton with Armijo backtracking | C 1, intercept on, 100 iterations, tol 1e-8 (L29–36) |
knn.h | KNeighborsClassifier, KNeighborsRegressor | Stores the data; brute-force search per query | k 5, uniform weights (L25–30) |
kmeans.h | KMeans | k-means++ then Lloyd, best of several restarts | k 8, 4 restarts, 300 iterations, tol 1e-4, seed 0 (L30–38) |
pca.h | Pca | SVD of the centered data | all min(n, d) components, no whitening (L26–31) |
naive_bayes.h | GaussianNb, MultinomialNb | Counting, in log space | var_smoothing 1e-9; alpha 1 (L32–34, L59–61) |
decision_tree.h | DecisionTreeClassifier, DecisionTreeRegressor | Greedy CART: sort and sweep each feature | Gini; unlimited depth, split 2, leaf 1, all features (L38–46) |
random_forest.h | RandomForestClassifier, RandomForestRegressor | Bootstrap samples, one tree each, in parallel | 100 trees, bootstrap on; √d features per split for the classifier, all for the regressor (L29–35, L60–65) |
gradient_boosting.h | GradientBoostingRegressor, GradientBoostingClassifier | Histogram trees grown best-first on gradients and Hessians | 100 rounds, lr 0.1, 31 leaves, unlimited depth, leaf 20, l2 0, 255 bins (L35–43) |
svm.h | SupportVectorClassifier | SMO on the dual with a full kernel matrix | RBF, C 1, gamma "scale", degree 3, coef0 0, tol 1e-3, 107 iterations (L46–56) |
Linear models
LinearRegression
The objective is ‖y − Xw − b‖² + l2‖w‖², ordinary least squares at l2 = 0
(linear_regression.h L8–19). Fit
centers X and y when fit_intercept is on, which removes the intercept from the problem and keeps
it unpenalized, solves for w, then recovers b = mean(y) − mean(x)·w
(linear_regression.cc L67–102).
- Normal equations. Forms XcᵀXc + l2·I and factors it with
Cholesky; a failed factorization is the singular case and returns
InvalidArgumentwith "set l2 > 0 for ridge regression" (L19–31). - Gradient descent. Full-batch steps on the objective divided by n, stopping once the
gradient's largest entry is below
tolerance; a non-finite gradient returns "gradient descent diverged; lower the learning rate" (L33–63). The header states the convergence condition, lr < 2 / λmax, and says to standardize first.
LogisticRegression
Binary problems fit one weight row through a sigmoid, K > 2 classes fit K rows through a softmax. The
objective is the summed negative log-likelihood plus ‖W‖²/(2C), scikit-learn's, so the optimum is the same
and the intercept is unpenalized (logistic_regression.h
L7–18). Parameters are packed one output at a time as [w_0 … w_{d−1}, b] against an
augmented input [x, 1] (logistic_regression.cc
L20–34).
- Each iteration builds the full gradient and Hessian: residual p − y and curvature p(1 − p) (binary) or diag(p) − ppᵀ (softmax), plus 1/C on the weights and a 10−8 jitter on the whole diagonal, since a softmax's biases are only fixed up to a constant (L93–143).
- The Newton step solves H·d = g by Cholesky; then Armijo backtracking halves t until the objective drops by 10−4·t·gᵀd, up to 50 halvings (L171–189).
- It stops when the largest gradient entry is under
tolerance, or when no step lowers the objective at all, which it treats as converged at double precision (L163–199). The loss uses max(z, 0) + log1p(e−|z|) − yz, so separable data never overflows (L72–75).
coef() is [1, d] for two classes, the class-1 weights as in scikit-learn, and
[K, d] otherwise (L49–54). The Hessian
has (outputs·(d + 1))² entries, so the header scopes the class to small d.
Neighbors and clusters
k-nearest neighbors
Fit stores the training matrix and fails when k > n
(knn.cc L63–78). Each query computes every squared distance,
partial_sorts the k smallest with ties broken by training-row order, and takes square roots only
of those (L21–39). Weights are uniform or 1/r; when the nearest
neighbor is at distance 0, only the exact matches count, since their weight would be infinite
(L41–55). Queries run under ParallelFor with a grain
of 16 (L61); each writes only its own output row.
KMeans
- Seeding.
KMeansPlusPluspicks the first center uniformly, then each next one with probability proportional to its squared distance from the nearest chosen center; with fewer distinct points than k it falls back to a uniform pick (kmeans.cc L104–140). - Lloyd. Assign every point to its nearest center (lowest index on ties), move every center to its points' mean, stop when the summed squared center shift is at most the tolerance (L60–98). An empty cluster restarts at the point farthest from its center (L78–84).
- Tolerance and restarts.
toleranceis relative: it is multiplied by the data's mean per-feature variance (L151–160). Restart r draws fromGenerator(seed, r), and the run with the least inertia wins (L144–174).
PCA and naive Bayes
Pca
Fit centers X and takes its SVD rather than eigendecomposing the
covariance, which would square the condition number
(pca.h L7–15). The components are the first c columns of V, the
explained variance is s²/(n − 1), and the ratio divides by the total over all singular values
(pca.cc L19–62). The sign convention that makes components match
scikit-learn, largest-magnitude entry positive, lives in SvdFactor itself
(svd.h L24). Transform projects and, with
whiten, divides by √variance; InverseTransform undoes both
(L64–104).
GaussianNb and MultinomialNb
Both fit by counting and score in log space, normalizing each row with log-sum-exp
(naive_bayes.cc L30–45). Every class in
0..max(y) needs at least one sample, or Fit returns InvalidArgument
(L20–28).
- Gaussian. Per-class means and population variances, plus
var_smoothingtimes the largest variance of any feature over the whole data, so a feature that is constant within a class does not divide by zero (L57–104). - Multinomial. Smoothed log-probabilities log(count + α) − log(total + dα); negative
counts return
InvalidArgument, and a zero count skips its term so α = 0 cannot give 0·log 0 (L134–184).
Trees
The tree code is split into a public pair of estimators and an internal grower that the forest
reuses (decision_tree.h L48–89). A Tree is a
flat vector of TreeNodes with the root at index 0; a node with feature = −1 is a
leaf, and value holds class frequencies or the one mean target.
Growing a CART tree
- Leaf test. A node is a leaf at
max_depth, belowmin_samples_split, below twicemin_samples_leaf, or with impurity ≤ 10−12 (decision_tree.cc L191–193). - Sweep. For each candidate feature, sort the node's (value, row) pairs, then move rows
one at a time from a right
Statsto a left one, which keeps counts (or sum and sum of squares) up to date in O(1). Each boundary between distinct values that leaves both sides ≥min_samples_leafis scored by nL·IL + nR·IR, and the threshold is the midpoint (L128–158). - Zero-gain splits. The best split is taken even when it lowers impurity by nothing, which XOR needs before its second level pays off (decision_tree.h L15–18).
- Recurse.
std::partitionthe rows in place, add n·I − (nLIL + nRIR) to the feature's importance, and recompute a regression child's variance in two passes, so a constant child is exactly pure (L180–220).
Worked example. The first tree test fits x = 1, 2, 3, 10, 11, 12 with labels 0, 0, 0, 1, 1, 1 (decision_tree_test.cc L23–33). The root's Gini impurity is 1 − (0.5² + 0.5²) = 0.5. The sweep scores five boundaries:
SplitsHalfwayBetweenClasses: one sort, five candidate thresholds,
each scored from running class counts.Limits and feature subsets
| Item | Meaning | Source |
|---|---|---|
SplitCriterion | kGini (1 − Σp²) or kEntropy (−Σp log₂ p) for classifiers; regressors always use variance. | decision_tree.cc L41–61 |
TreeLimits | max_depth −1 (unlimited), min_samples_split 2, min_samples_leaf 1, max_features 0, feature_subset kAll. | decision_tree.h L38–46 |
FeaturesPerSplit | max_features when positive, else ⌊√d⌋ or ⌊log₂ d⌋ for kSqrt / kLog2, clamped to [1, d]. | L237–248 |
| Subsampling | With fewer than d features per split, a partial Fisher–Yates shuffle draws features until enough non-constant ones have been tried, so constant features never use up the budget. | L160–178 |
feature_importances() | The summed impurity decreases normalized to 1; all zero for a single leaf. | L271–279 |
Random forests
GrowForest grows tree t inside a ParallelFor with grain 1, drawing the bootstrap
rows and the feature subsets from Generator(seed, t), so the forest depends on the seed and not
on which thread grew which tree (random_forest.cc
L22–43). Bootstrap rows may repeat, which GrowTree accepts. The classifier's default limits
are {-1, 2, 1, 0, FeatureSubset::kSqrt} (random_forest.h
L32); the regressor uses all features. Predictions average the trees' leaf values (class frequencies or
means), and importances average each tree's normalized importances
(L45–55).
Gradient boosting
Boosting fits F(x) = F₀ + Σ lr·ft(x), each tree on the per-sample gradient g and Hessian h of the
loss at the current scores. A leaf with sums G and H outputs the Newton step −G/(H + l2), and a split's gain is
GL²/(HL + l2) + GR²/(HR + l2) − G²/(H + l2)
(gradient_boosting.h L7–24). The shared loop is
internal::Booster; the regressor uses squared error, the classifier log loss with one tree per
round for two classes and one per class otherwise (L76–102).
| Piece | What it does | Source |
|---|---|---|
FitBinMapper | Per feature: midpoints between distinct values when there are at most max_bins of them, otherwise quantile thresholds that always stay below the column's maximum, so the top bin is never empty. max_bins must be in [2, 256], since bins are uint8_t. | gradient_boosting.cc L30–55 |
BinnedData | Every value's bin, stored feature-major so one feature's histogram reads one contiguous column. | L98–129 |
BuildHistogram | Sums (g, h, count) per bin, one feature per ParallelFor task, each writing its own slice of the histogram. | L176–190 |
FindSplit | Sweeps each feature's bins left to right; skips splits with a child under min_samples_leaf or with Hessian under 10−3; keeps only positive gain. | L194–216 |
SplitLeaf | Partitions the rows, builds the smaller child's histogram and gets the larger one by subtracting it from the parent's. | L218–256 |
Grow | Best-first: repeatedly splits the leaf whose best split has the largest gain until max_leaf_nodes leaves or no positive gain, then writes −lr·G/(H + l2) into each leaf and adds it to the training scores. | L138–173 |
Booster::Fit | Starts from the mean target, the log-odds of the prior (binary), or log priors (multiclass, clamped to [10−15, 1 − 10−15]); each round computes g and h (F − y and 1; p − y and p(1 − p), per class under softmax), grows one tree per column, and records the mean training loss. | L283–370 |
Worked example. One round with lr 1, l2 2 and leaf size 1 on x = 0, 1, 2, 3, y = 0, 0, 10, 10 (gradient_boosting_test.cc L52–70): the baseline is the mean, 5, so g = F − y = 5, 5, −5, −5 and h = 1. The split at 1.5 has GL = 10, HL = 2 on the left, so that leaf outputs −10/(2 + 2) = −2.5 and the right one +2.5, and the predictions are 2.5 and 7.5. Its gain is 10²/4 + 10²/4 − 0 = 50; the code drops the factor ½ the header mentions, which does not change which split wins.
Support vector machine
SupportVectorClassifier is binary, labels 0 and 1 mapped to −1 and +1, and solves the
soft-margin dual, min ½ Σ αiαjyiyjKij − Σ αi
subject to 0 ≤ α ≤ C and Σ αiyi = 0, by sequential minimal optimization
(svm.h L8–27). The full n × n kernel matrix is computed once, which
the header scopes to a few thousand points.
- Kernel and gamma. Linear x·z, polynomial (γx·z + coef0)degree or RBF
exp(−γ‖x − z‖²);
gamma = 0resolves to 1/(d·Var(x)) over every entry, scikit-learn's"scale"(svm.cc L24–49). - Working set. The gradient Qα − 1 starts at −1 with α = 0. Each step picks i, the largest violator −ytGt among indices that can move up, and j, the index in the low set with the largest guaranteed decrease b²/a, LIBSVM's second-order rule, flooring the curvature a at 10−12 (L81–128).
- Stop. When the maximal violation is under
tolerance(1e-3, LIBSVM's default); reachingmax_iterationsfirst returnsInternal(L129–134). - Step. Move αi and αj along the line that keeps Σαy fixed, to the parabola's minimum clipped to the box, snap values within 10−12·C of a bound onto it, and update the gradient in O(n) (L136–153).
- Bias and support. b averages over the free support vectors, or takes the midpoint of the interval the bounded ones allow when none is free; the support vectors (α > 0) and their α·y are kept for prediction (L156–194).
Checked against scikit-learn
tools/make_sklearn_fixtures.py writes rml/classical/testdata/sklearn.json: small datasets from a
default_rng(0) stream, rounded through float32 so both sides see identical inputs, and what
scikit-learn fits on them (make_sklearn_fixtures.py L27–29). The committed
file records scikit-learn 1.9.1. sklearn_fixture_test reads it with nlohmann/json, so
bazel test needs no Python (sklearn_fixture_test.cc
L29–36, BUILD.bazel L275–292).
| Fixture | Data and scikit-learn settings | What rml must match | Source |
|---|---|---|---|
linear | 40 × 3, y = x·[1.5, −2, 0.5] + 0.3 + noise; LinearRegression, Ridge(alpha=1.5) | OLS and ridge coefficients and intercepts to 1e-5 | L40–49, test L66–81 |
logistic_binary, logistic_multinomial | 60 × 2 at C = 0.5; three 30-point blobs at C = 1; tol=1e-12 | Coefficients and intercepts to 1e-4, the first five probability rows to 1e-5 (one TEST_P, two cases) | L51–68, test L83–98 |
pca | 50 × 4 correlated, 3 components | Explained variance and singular values to 1e-4, ratios to 1e-6, and |cos| between matching components within 1e-5 of 1 | L70–78, test L100–119 |
gaussian_nb, multinomial_nb | 45 points in three blobs; 30 Poisson count rows at α = 0.5 | Priors, means, smoothed variances, log-probabilities and the first five probability rows | L80–98, test L121–142 |
knn | 40 × 2 XOR-signed labels; k = 5 distance-weighted classifier, k = 4 uniform regressor; 10 queries | Probabilities and predictions to 1e-5 | L100–110, test L144–160 |
decision_tree_regressor | 60 points of a noisy sine, one feature so split ties cannot differ; depth 3, leaf 3 | Predictions at 13 query points to 1e-5 | L112–121, test L162–170 |
kmeans | 80 points in four well-separated blobs, n_init=10 | Inertia to 1e-3 (rml run with tolerance 1e-10); the fixture's centers are not compared | L123–131, test L172–180 |
svm | 80 overlapping points; RBF ("scale"), linear, and degree-3 polynomial with γ 0.5, coef0 1; C = 2, tol=1e-6 | The resolved gamma, the exact support set, the intercept and decision values at 10 queries to 1e-4 | L133–146, test L182–206 |
Random forests, gradient boosting and the tree classifier have no fixture: their results depend on random draws and tie-breaking that scikit-learn makes differently, so their own tests check properties instead.
Who uses it
Two callers outside the module. Retrieval's IvfIndex::Train clusters a
sample into its inverted lists with KMeans at one restart and 50 iterations
(ivf.cc L39–47), and the time-series example fits its
seasonal autoregression with LinearRegression
(time_series.cc L115–130). The
inference stack leaves classical ML out of scope.
Tests
| File | Tests | What they pin down |
|---|---|---|
linear_regression_test.cc | 6 | An exact linear map recovered, fits through the origin without an intercept, ridge shrinking toward zero, collinear features failing until l2 > 0, gradient descent agreeing with the normal equations, and divergence reported. |
logistic_regression_test.cc | 6 | The symmetric two-point closed form w = 2σ(−w) ≈ 0.6748 with b = 0, a stationary binary fit, a stronger penalty shrinking weights, multinomial blobs separated, separable data staying finite, one class an error. |
knn_test.cc | 5 | Votes among the nearest, distance weights favoring closer points, deterministic tie-breaks, k > n an error, the regressor's mean. |
kmeans_test.cc | 6 | Separated blobs recovered, inertia against a hand computation, same seed same result, distinct k-means++ seeds, more clusters than distinct points, k > n an error. |
pca_test.cc | 6 | The long axis found, uncorrelated projections with the component variances, a full inverse round trip, unit-variance whitening, truncated reconstruction dropping minor variance, bad component counts rejected. |
naive_bayes_test.cc | 5 | A hand-computed Gaussian posterior, smoothing over constant features, a missing class an error, Laplace-smoothed multinomial estimates, negative counts an error. |
decision_tree_test.cc | 10 | The halfway split above, XOR through a zero-gain split under both criteria, depth 0 as the class prior, importance on the informative feature, leaf and split limits, seeded feature subsampling, FeaturesPerSplit, a regression stump's side means, memorizing distinct points, a constant target as one leaf. |
random_forest_test.cc | 4 | A forest beating a single tree by 3 points on noisy data, same seed same forest, one tree without bootstrap or subsampling equal to a decision tree, averaging lowering regression test error. |
gradient_boosting_test.cc | 9 | Midpoint and quantile bins, the one-round Newton step above, zero rounds predicting the mean or the prior, a noisy sine, a curved binary boundary, multiclass blobs, one class an error. |
svm_test.cc | 7 | The maximum-margin line on separable blobs, a smaller C giving a wider soft margin, XOR by RBF and by a degree-2 polynomial, the scaled gamma, bad labels rejected, non-convergence reported. |
sklearn_fixture_test.cc | 9 | The fixture table above: eight TESTs and one TEST_P run for the binary and multinomial logistic fixtures. |