Qu

Machine Learning for Signals

The idea

A classifier does not want raw samples. It wants a short vector of numbers — features — computed the same way from every example, that already separate the classes at hand. A peak frequency, a band power, a spectral centroid: exactly the quantities Fourier analysis already produces. The signal processing does the seeing; the model only has to decide.

For a binary classifier, logistic regression fits a linear score to a sigmoid. Given a feature vector \(f\), weights \(w\), and bias \(b\):

\[ z = w^\top f + b, \qquad p = \sigma(z) = \frac{1}{1 + e^{-z}} \]

zz = (-800 to 800) / 100
p = 1 / (1 + exp(-zz))
figure()
plot(zz, p, color = "#0f172a", width = 2)
xlabel("z = w . f + b")
ylabel("p = sigma(z)")
title("The logistic function")
output
· plot: figure 1 (1601 points)
0 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1.0 -8 -6 -4 -2 0 2 4 6 8 The logistic function z = w . f + b p = sigma(z)

\(p\) is fit, from labeled examples, to land near 1 for one class and near 0 for the other, by minimizing the cross-entropy loss

\[ \mathcal{L}(w, b) = -\frac{1}{N}\sum_{i=1}^{N} \Big[y_i \log p_i + (1 - y_i)\log(1 - p_i)\Big] \]

via gradient descent, \(w \leftarrow w - \eta\nabla_w\mathcal{L}\). That update is where a detail invisible in the formula becomes decisive: the gradient's size scales with the feature's own scale. A step size \(\eta\) tuned for a feature of order 1 sends a feature of order 100–1000 into wild overshoot, and a feature near zero barely moves at all. A classifier can have every class perfectly separable and still fail to fit, because the optimizer, not the data, is where the fit breaks:

seed(9)
n = 40
f1_a = 0.3 + 0.15 * randn(n, seed = 1)
f2_a = 200 + 40 * randn(n, seed = 2)
f1_b = 0.7 + 0.15 * randn(n, seed = 3)
f2_b = 800 + 40 * randn(n, seed = 4)
figure()
scatter(f1_a, f2_a, color = "#0f172a", label = "class A")
scatter(f1_b, f2_b, color = "#e11d48", label = "class B")
xlabel("feature 1 (range ~0-1)")
ylabel("feature 2 (range ~0-1000)")
title("Two classes, two features on very different scales")
legend()
output
· scatter: figure 2 (40 points)
· scatter: figure 3 (40 points)
100 200 300 400 500 600 700 800 900 -0.10 0 0.10 0.20 0.30 0.40 0.50 0.60 0.70 0.80 0.90 x=0.413, y=174.925 x=0.121, y=188.962 x=0.379, y=191.381 x=0.262, y=199.939 x=0.216, y=182.996 x=0.156, y=151.717 x=0.144, y=251.071 x=0.127, y=222.003 x=0.26, y=159.094 x=0.368, y=267.433 x=-0.036, y=166.821 x=0.229, y=251.446 x=-0.068, y=169.365 x=0.419, y=239.022 x=0.292, y=241.619 x=0.176, y=195.769 x=0.296, y=243.547 x=0.137, y=189.548 x=0.347, y=298.4 x=0.387, y=218.525 x=0.311, y=275.349 x=0.394, y=218.668 x=0.26, y=251.105 x=0.526, y=210.283 x=0.279, y=156.953 x=0.162, y=187.514 x=0.259, y=166.96 x=0.041, y=143.634 x=0.58, y=153.009 x=0.224, y=260.237 x=0.305, y=247.702 x=0.269, y=259.984 x=0.533, y=167.721 x=-0.077, y=135.122 x=0.463, y=200.548 x=0.392, y=118.87 x=0.182, y=194.008 x=0.169, y=137.126 x=0.328, y=146.612 x=0.114, y=211.416 x=0.604, y=812.084 x=0.772, y=762.421 x=0.794, y=836.282 x=0.627, y=821.045 x=0.677, y=812.176 x=0.577, y=767.246 x=0.655, y=820.858 x=0.665, y=778.862 x=0.848, y=805.442 x=0.765, y=746.611 x=0.674, y=709.162 x=0.504, y=721.769 x=0.715, y=783.757 x=0.505, y=800.036 x=0.673, y=833.366 x=0.745, y=752.699 x=0.638, y=855.871 x=0.439, y=783.838 x=0.603, y=803.656 x=0.816, y=798.149 x=0.653, y=808.383 x=0.666, y=834.543 x=0.825, y=845.82 x=0.29, y=844.516 x=0.584, y=805.76 x=0.575, y=751.026 x=0.556, y=788.316 x=0.818, y=817.619 x=0.59, y=822.597 x=0.586, y=727.273 x=0.901, y=773.038 x=0.501, y=751.836 x=0.491, y=828.245 x=0.687, y=794.312 x=0.637, y=773.435 x=0.369, y=747.775 x=0.607, y=867.271 x=0.363, y=736.634 x=0.49, y=777.231 x=0.734, y=791.228 Two classes, two features on very different scales feature 1 (range ~0-1) feature 2 (range ~0-1000) class A class B

Both classes separate cleanly on either feature alone. The scales themselves — not the data, not the model family — are what a gradient descent fit is sensitive to, and fixing it is a matter of putting every feature on comparable footing before fitting, not of collecting more data.

In Qu

Two classes of synthetic signal: a 100 Hz tone in noise and a 250 Hz tone in noise, 60 examples each, 40% noise. Extract one feature — the frequency of each signal's strongest spectral peak, in raw hertz — and fit:

seed(42)
fs = 2000
N = 256
per_class = 60
t = (0 to N - 1) / fs

function peak_only(sig)
    mag = abs(rfft(sig))
    f = (0 to length(mag) - 1) * fs / N
    return [f[argmax(mag)]]
end function

X = zeros(per_class * 2, 1)
y = zeros(per_class * 2)
row = 0
for i in 0 to per_class - 1
    sig = sin(2*pi*100*t) + 0.4 * randn(N, seed = 1000 + i)
    X[row, :] = peak_only(sig)
    y[row] = 0
    row = row + 1
end for
for i in 0 to per_class - 1
    sig = sin(2*pi*250*t) + 0.4 * randn(N, seed = 2000 + i)
    X[row, :] = peak_only(sig)
    y[row] = 1
    row = row + 1
end for

s = train_test_split(X, y, test_size = 0.3, seed = 7)
m = logistic_model(s.X_train, s.y_train)
print("raw-Hz peak frequency, test accuracy: {round(m.score(s.X_test, s.y_test), 4)}")
print("coef: {round(m.coef, 4)}, intercept: {round(m.intercept, 4)}")
output
raw-Hz peak frequency, test accuracy: 0.4722
coef: [10.6641], intercept: -20.7316
raw-Hz peak frequency, test accuracy: 0.4722
coef: [10.6641], intercept: -20.7316

47% — worse than a coin flip, on a problem where the two classes sit at 100 Hz and 250 Hz with zero overlap. The peak frequencies really do separate perfectly; a raw hertz value of order 100–250 is exactly the "feature 2" situation from Part 1, and the fit never converged anywhere near a sane boundary. The fix is Qu's standardize, not a hand-picked unit conversion:

Xs = standardize(X)
s2 = train_test_split(Xs, y, test_size = 0.3, seed = 7)
m2 = logistic_model(s2.X_train, s2.y_train)
print("standardize(X), test accuracy: {round(m2.score(s2.X_test, s2.y_test), 4)}")
print("coef: {round(m2.coef, 4)}, intercept: {round(m2.intercept, 4)}")

mu = mean(X)
sigma = std(X)
boundary_raw = -m.intercept / m.coef[0]
boundary_scaled_in_hz = mu + (-m2.intercept / m2.coef[0]) * sigma
print("unscaled decision boundary: {round(boundary_raw,2)} Hz")
print("standardized decision boundary, mapped back: {round(boundary_scaled_in_hz,2)} Hz")
output
standardize(X), test accuracy: 1
coef: [4.5811], intercept: 0.0245
unscaled decision boundary: 1.94 Hz
standardized decision boundary, mapped back: 175.38 Hz
standardize(X), test accuracy: 1
coef: [4.5811], intercept: 0.0245
unscaled decision boundary: 1.94 Hz
standardized decision boundary, mapped back: 175.38 Hz

Perfect, on held-out data, once every feature is (x - mean) / std before fitting. The two boundaries make the failure visible rather than abstract: the standardized model's boundary, mapped back into hertz, lands at 175 Hz — exactly the midpoint of 100 and 250 — while the unscaled model's boundary sits at 1.94 Hz, nowhere near any data the model was ever shown.

class0 = X[0:per_class-1, 0]
class1 = X[per_class:per_class*2-1, 0]
jitter0 = 0.05 * randn(per_class, seed = 100)
jitter1 = 1 + 0.05 * randn(per_class, seed = 200)
figure()
scatter(class0, jitter0, color = "#0f172a", label = "class 0 (100 Hz tone)")
scatter(class1, jitter1, color = "#e11d48", label = "class 1 (250 Hz tone)")
plot([boundary_raw, boundary_raw], [-0.3, 1.3], color = "#94a3b8", width = 1.5, label = "unscaled boundary")
plot([boundary_scaled_in_hz, boundary_scaled_in_hz], [-0.3, 1.3], color = "#228833", width = 1.5, label = "standardized boundary")
xlabel("peak frequency (Hz)")
ylabel("class (jittered for display)")
title("Same feature space, two decision boundaries")
legend()
output
· scatter: figure 4 (60 points)
· scatter: figure 5 (60 points)
· plot: figure 6 (2 points)
· plot: figure 7 (2 points)
-0.2 0 0.2 0.4 0.6 0.8 1.0 1.2 0 40 80 120 160 200 240 x=101.563, y=0.006 x=101.563, y=-0.041 x=101.563, y=-0.044 x=101.563, y=0.023 x=101.563, y=0.051 x=101.563, y=0.036 x=101.563, y=0.064 x=101.563, y=-0.068 x=101.563, y=-0.052 x=101.563, y=0.053 x=101.563, y=0.077 x=101.563, y=-0.006 x=101.563, y=-0.024 x=101.563, y=0.001 x=101.563, y=0.035 x=101.563, y=0.002 x=101.563, y=-0.007 x=101.563, y=-0.006 x=101.563, y=-0.082 x=101.563, y=-0.025 x=101.563, y=0.069 x=101.563, y=0.034 x=101.563, y=-0.021 x=101.563, y=0.012 x=101.563, y=-0.04 x=101.563, y=0.039 x=101.563, y=0.053 x=101.563, y=0.041 x=101.563, y=0.051 x=101.563, y=0.058 x=101.563, y=0.083 x=101.563, y=0.005 x=101.563, y=0.026 x=101.563, y=-0.035 x=101.563, y=-0.024 x=101.563, y=-0.084 x=101.563, y=-0.015 x=101.563, y=0.107 x=101.563, y=-0.054 x=101.563, y=-0.004 x=101.563, y=-0.031 x=101.563, y=0.033 x=101.563, y=0.042 x=101.563, y=0.034 x=101.563, y=0.063 x=101.563, y=0.095 x=101.563, y=-0.025 x=101.563, y=0.039 x=101.563, y=-0.01 x=101.563, y=-0.035 x=101.563, y=-0.084 x=101.563, y=0.032 x=101.563, y=0.048 x=101.563, y=-0.017 x=101.563, y=0.014 x=101.563, y=0.026 x=101.563, y=-0.005 x=101.563, y=-0.028 x=101.563, y=0.006 x=101.563, y=0.01 x=250, y=0.99 x=250, y=0.994 x=250, y=1.02 x=250, y=1.039 x=250, y=0.979 x=250, y=1.042 x=250, y=1.037 x=250, y=1.118 x=250, y=0.944 x=250, y=1.008 x=250, y=0.975 x=250, y=0.998 x=250, y=1.001 x=250, y=0.98 x=250, y=1.028 x=250, y=0.946 x=250, y=0.964 x=250, y=1.025 x=250, y=1.047 x=250, y=0.953 x=250, y=1.007 x=250, y=1.098 x=250, y=1.032 x=250, y=0.933 x=250, y=1.01 x=250, y=1.046 x=250, y=0.964 x=250, y=0.975 x=250, y=1.053 x=250, y=0.967 x=250, y=1.049 x=250, y=0.953 x=250, y=0.983 x=250, y=0.917 x=250, y=1.08 x=250, y=1.03 x=250, y=0.953 x=250, y=1.044 x=250, y=1.029 x=250, y=0.985 x=250, y=0.974 x=250, y=0.958 x=250, y=1.045 x=250, y=0.966 x=250, y=0.983 x=250, y=1.06 x=250, y=1.018 x=250, y=0.999 x=250, y=0.988 x=250, y=1.034 x=250, y=0.933 x=250, y=1.043 x=250, y=0.929 x=250, y=1.002 x=250, y=0.893 x=250, y=1.048 x=250, y=0.931 x=250, y=1.019 x=250, y=1.036 x=250, y=1.016 Same feature space, two decision boundaries peak frequency (Hz) class (jittered for display) class 0 (100 Hz tone) class 1 (250 Hz tone) unscaled boundary standardized boundary

The unscaled boundary sits off the left edge of the data entirely — a model that, in effect, never separated anything, despite 47% looking like a number a broken system could still produce by chance. The standardized boundary sits cleanly between the two clusters, precisely where a person looking at the scatter would draw it by hand.

Both fits used exactly the same model, logistic_model, on exactly the same fifteen-word difference in preprocessing. The lesson is not that scaling is a checkbox: it is that "the classes are obviously different" and "the optimizer can find that difference" are separate claims, and only testing the fit — not eyeballing the data — tells them apart.

What was assumed away

Xs and every array before it lived as ordinary 64-bit floating point numbers, computed by an FFT and a sigmoid that assumed as many bits and as much power as anyone likes to spend on each one. That assumption is free on a laptop and is not free on a hearing-aid chip, a satellite, or a battery-powered vibration logger running for a year on one cell. Lesson 22 removes it, and asks what happens to a filter — or a classifier's arithmetic — once it has to run in fixed point.