Which function makes which shape. What goes into the trainer. What every layer does with it. Every figure is the real output of the real code, run today.
→ next slide · space next step inside a slide · click right half = forward, left half = back
A shaft at 3000 rpm = 50 Hz. An accelerometer at 1000 samples per second. One window = 128 samples = 128 ms. The job: for every window, name the state. imbalance is louder. bearing_fault is as loud as normal, but with spikes.
steps/01_generate_data.py, function by function. Left: the code. Right: what that code returns.
SAMPLE_RATE_HZ = 1000.0 WINDOW = 128 t = np.arange(WINDOW) / SAMPLE_RATE_HZ
Everything starts with 128 time stamps, one millisecond apart. Every generator function receives this array and returns one value per time stamp.
In generate() a random offset is added: t + rng.uniform(0, 1/50), so a window may start anywhere within one shaft turn.
def _window_normal(rng, t):
fundamental = 1.0 * np.sin(2 * np.pi * SHAFT_FREQ_HZ * t)
second_harmonic = 0.15 * np.sin(2 * np.pi * 2 * SHAFT_FREQ_HZ * t)
return fundamental + second_harmonic + rng.normal(0.0, 0.05, t.shape)
Three terms, one return. The figure shows each term and the sum, for one call.
def _window_imbalance(rng, t):
fundamental = (rng.uniform(2.2, 3.0)
* np.sin(2 * np.pi * SHAFT_FREQ_HZ * t))
second_harmonic = 0.2 * np.sin(2 * np.pi * 2 * SHAFT_FREQ_HZ * t)
return fundamental + second_harmonic + rng.normal(0.0, 0.05, t.shape)
Same three terms. The only real change: the shaft amplitude is drawn between 2.2 and 3.0 for every window (this call drew ).
Same y axis as the previous figure: the sum is simply two to three times taller. Nothing else differs.
def _make_burst(rng, length):
local_t = np.arange(length) / SAMPLE_RATE_HZ
envelope = np.exp(-local_t * DECAY_PER_SECOND) # 400
burst = (rng.uniform(1.5, 2.5) * envelope
* np.sin(2 * np.pi * RESONANCE_HZ * local_t)) # 220 Hz
return burst - burst.mean()
One impact of one ball on the pit. Four panels, left to right: the decaying envelope, the 220 Hz ring, their product, and the product with its mean removed.
The envelope is at 6 % after 7 samples, so the burst is effectively 7 ms long although the array has 24 entries.
Without - burst.mean() every burst carries a positive offset (the yellow line in panel 3). Summed over a window that is a 0 Hz line no real accelerometer produces.
def _window_bearing_fault(rng, t):
signal = _window_normal(rng, t)
spacing = int(SAMPLE_RATE_HZ / rng.uniform(90.0, 130.0))
for start in range(rng.integers(0, spacing), len(t), spacing):
length = min(BURST_SAMPLES, len(t) - start)
signal[start : start + length] += _make_burst(rng, length)
return signal
Literally normal plus impulses. Top: the normal window it starts from. Middle: the bursts that get added, dotted lines at every start (this call: spacing samples, impacts). Bottom: the sum, which is what the function returns.
Both classes carry the same 50 Hz energy. That is why loudness alone cannot separate them.
GENERATORS = (_window_normal, _window_imbalance, _window_bearing_fault)
def generate(count_per_class, seed, window_length=WINDOW):
rng = np.random.default_rng(seed)
t = np.arange(window_length) / SAMPLE_RATE_HZ
for class_index, generator in enumerate(GENERATORS):
for _ in range(count_per_class): # 2000
offset = rng.uniform(0.0, 1.0 / SHAFT_FREQ_HZ)
windows.append(generator(rng, t + offset))
labels.append(class_index)
X = np.stack(windows) # (6000, 128)
y = np.array(labels, dtype=np.int64) # (6000,)
What it is. A table with 6000 rows. One row = one window = 128 numbers. Next to it a list y with one number per row: 0, 1 or 2.
How it works. Each generator function is called 2000 times. Every call returns 128 numbers. np.stack lays the returned windows under each other, in the order they were made: first all normal, then all imbalance, then all bearing_fault.
Why it is there. A network trains on many examples at once. The table is the form Keras expects: one example per row, the answer in a separate list.
shuffle = rng.permutation(len(y))
X, y = X[shuffle], y[shuffle]
What it is. A new random order of the 6000 rows.
How it works. rng.permutation(6000) is a list of the numbers 0 to 5999 in random order. Indexing X and y with the same list moves every window together with its answer, like shuffling cards with the name written on each card.
Why it is there. Step 2 cuts the table by position: the first 4800 rows train, the last 1200 test. Without shuffling, the test rows would all be bearing_fault and the training rows would never contain one.
X = X.reshape(-1, 1, window_length, 1)
return X, y # X: (6000, 1, 128, 1)
What it is. The same 768 000 numbers, sorted into four labelled axes instead of two.
How it works. Keras treats a signal like a picture: rows × columns × colour channels. A window becomes a picture with 1 row, 128 columns and 1 channel. No number moves, only the labels change.
Why it is there. Every layer in the network is written as a 2-D picture operation, because that is what the NPU converter maps reliably. Height 1 from here to the end.
scale = float(np.percentile(np.abs(X), 99.5)) # 2.9239
X = np.clip(X / scale, -1.0, 1.0).astype(np.float32)
np.savez_compressed("data.npz", X=X, y=y, scale=scale,
fs=np.float32(SAMPLE_RATE_HZ), classes=np.array(CLASSES))
What it is. One division. Every number in X is divided by the same constant, scale = 2.9239.
How it works. scale is picked so that 99.5 % of all values fit inside ±scale. After the division everything lies between −1 and 1. The shape of every window is unchanged. The constant is saved into data.npz next to X and y.
Why it is there. The NPU stores each value as an integer from −128 to 127. Spread over ±1 all 256 steps are used. The board later divides its live signal by the same constant, so the number has to travel with the model.
steps/02_train_model.py: what it loads, how it splits, what a batch looks like, what comes out.
data = np.load("data.npz", allow_pickle=False)
X, y = data["X"], data["y"]
classes = [str(c) for c in data["classes"]] # 3 names
_, height, window, channels = X.shape # 1, 128, 1
assert height == 1
What the trainer receives:
| key | shape | content |
|---|---|---|
X | (6000, 1, 128, 1) float32 | 6000 windows in [-1, 1], shuffled |
y | (6000,) int64 | class index per window: 0, 1, 2 |
scale | scalar | , for the board |
classes | (3,) str | the names, in index order |
cut = int(0.8 * len(y)) # 4800 X_train, y_train = X[:cut], y[:cut] # 4800 windows X_test, y_test = X[cut:], y[cut:] # 1200 windows model.fit(X_train, y_train, validation_split=0.1, ...) # 4320 fit, 480 validation model.evaluate(X_test, y_test) # 1200 test, once
Three disjoint sets, cut by position (step 1 already shuffled):
inputs = keras.Input(shape=(1, window, channels), name="signal") x = layers.Conv2D(16, (1, 7), strides=(1, 2), padding="same", activation="relu")(inputs) x = layers.Conv2D(32, (1, 5), strides=(1, 2), padding="same", activation="relu")(x) x = layers.Conv2D(64, (1, 3), strides=(1, 2), padding="same", activation="relu")(x) x = layers.AveragePooling2D(pool_size=(1, window // 8))(x) x = layers.Reshape((64,))(x) outputs = layers.Dense(num_classes, name="logits")(x) model = keras.Model(inputs, outputs, name="timeseries_cnn")
This is what model.summary() printed in today's run. Chapter 3 goes through every row with its real output.
| layer | type | output shape | params |
|---|---|---|---|
| signal | Input | (None, 1, 128, 1) | 0 |
| conv2d | Conv2D (1,7) s2 | (None, 1, 64, 16) | 128 |
| conv2d_1 | Conv2D (1,5) s2 | (None, 1, 32, 32) | 2 592 |
| conv2d_2 | Conv2D (1,3) s2 | (None, 1, 16, 64) | 6 208 |
| average_pooling2d | AveragePooling2D (1,16) | (None, 1, 1, 64) | 0 |
| reshape | Reshape | (None, 64) | 0 |
| logits | Dense | (None, 3) | 195 |
| Total | |||
model.compile(
optimizer=keras.optimizers.Adam(1e-3),
loss=keras.losses.SparseCategoricalCrossentropy(from_logits=True),
metrics=["accuracy"],
)
Loss: how wrong the network is on a batch. Cross-entropy is small when the true class has the largest logit by a wide margin, large when another class wins. Sparse because y holds integers, not one-hot vectors.
from_logits=True: the model outputs raw scores. The loss applies the softmax itself, during training only. That is why the saved graph has no softmax layer, and why the board computes it in numpy.
Adam decides how far to move each weight per step. Learning rate 0.001 is its default.
model.fit(X_train, y_train,
validation_split=0.1,
epochs=15,
batch_size=64,
verbose=2)
The fit set is cut into batches of 64 windows. The figure is the first batch of epoch 1, exactly as the network sees it: 64 rows, 128 columns, one colour per amplitude, with the 64 labels on the left.
For each batch: forward pass, loss, gradient, one Adam step on all weights. batches make one epoch. Then the 480 validation windows are scored. 15 epochs.
Epoch 1/15 68/68 - 0s - accuracy: 0.5289 - loss: 0.8164 - val_accuracy: 1.0000 - val_loss: 0.4196 Epoch 2/15 68/68 - 0s - accuracy: 1.0000 - loss: 0.1279 - val_accuracy: 1.0000 - val_loss: 0.0124 ... Epoch 15/15 68/68 - 0s - accuracy: 1.0000 - loss: 7.7510e-05 - val_accuracy: 1.0000 - val_loss: 7.5123e-05
Today's log, plotted. Blue: the fit set. Green: the validation set. Both fall together, so nothing is memorised. After epoch 2 every window is already classified correctly and the loss only sharpens the margins.
On a real dataset the green curve would flatten or rise while the blue keeps falling. That gap is overfitting, and the validation set exists to show it.
loss, accuracy = model.evaluate(X_test, y_test, verbose=0)
if accuracy < 0.90:
print("WARNING: below 90% - add epochs or data before quantising.")
predicted = np.argmax(model.predict(X_test), axis=1)
# confusion matrix: row = true, column = predicted
model.save("model.keras")
1200 windows the network has never seen. Rows: the true class from y_test. Columns: argmax of the three logits. The diagonal is right, everything else is wrong.
The matrix says which class fails. The accuracy hides that. The cell to watch is always imbalance ↔ bearing_fault, the pair a threshold could not separate.
Below 90 % stop here. INT8 can only lose accuracy, never gain it.
The network uses five kinds of layer. Each one is a small, fixed piece of arithmetic. First each kind on its own, with the real numbers from this model. Then the six layers in order.
layers.Conv2D(16, (1, 7), strides=(1, 2),
padding="same", activation="relu")
What it is. A kernel: 7 weights in a row. Weights are numbers the training found.
How it works. Lay the kernel over 7 neighbouring samples. Multiply each sample by the weight above it. Add the seven products and a bias. That is one output number. Move two samples to the right, repeat. 128 samples give 64 numbers. 16 kernels give 16 such rows, the channels.
Why it is there. It is a pattern detector. A kernel shaped like a short ring gives a large number wherever the signal has that ring. The network needs a detector that works at any position in the window, and a sliding kernel is exactly that.
activation="relu" # relu(x) = max(0, x)
What it is. A rule for every single number: below zero becomes 0, above zero stays.
How it works. It is applied to every one of the 64 × 16 numbers a convolution produces, right after the sum. Nothing is learned here, there are no weights.
Why it is there. After it, a positive number means "the kernel's pattern is here" and 0 means "not here". Without this cut, three convolutions in a row would be mathematically one convolution, and the network could not build a large pattern (a train of rings) out of small ones (one ring).
layers.AveragePooling2D(pool_size=(1, 16))
What it is. An average. For each channel, its 16 numbers along time become one number.
How it works. Channel 57 has 16 values, one per 8 ms slice of the window. Their mean is 1.64. That is done for all 64 channels. 16 × 64 numbers in, 64 numbers out. No weights.
Why it is there. After this the network knows how much each pattern occurred in the window, but no longer when. That is wanted: a bearing fault at 20 ms is the same fault as at 90 ms. And 64 numbers are enough for a decision.
layers.Reshape((64,)) # (1, 1, 1, 64) → (1, 64)
What it is. A re-labelling. The 64 numbers stay exactly the same.
How it works. After pooling the 64 numbers sit in a 4-axis box with shape (1, 1, 1, 64): one window, one row, one time step, 64 channels. Reshape drops the three axes of length 1 and leaves a plain list of 64.
Why it is there. The Dense layer that follows expects a plain list. Zero weights, zero arithmetic. It exists only to make the shapes fit.
layers.Dense(3, name="logits")
What it is. Three weighted sums. Each output has its own 64 weights and one bias.
How it works. Output = in[0]·w0 + in[1]·w1 + … + in[63]·w63 + bias. A red line in the picture is a positive weight (pushes the output up), a blue line a negative one (pulls it down). For this window the bearing_fault sum is 6.28 + 0.02 = 6.29. The other two sums are negative.
Why it is there. It turns 64 numbers of the kind "how much of pattern k" into 3 votes, one per class. The biggest vote is the answer. These are the logits.
What it isThe network is written with Conv2D layers whose kernels are 1 row high, (1, 7), (1, 5), (1, 3), instead of the Conv1D a time series would normally get. The signal is treated as a picture with one row.
How it worksTFLite has no 1-D convolution. On export every Conv1D becomes a Conv2D anyway, wrapped in Reshape and Squeeze nodes that add and remove the height axis around each layer. Written as height-1 Conv2D from the start, nothing has to be wrapped: the exported graph is exactly the layers we wrote.
Why it is thereThe Neutron converter maps convolutions, not Reshape or Squeeze. Every such node between two convolutions cuts the chain and lowers the Gate A ratio, and every cut is a CPU to NPU hand-over with its own fixed cost. Route B keeps one unbroken chain: 9 / 9, one NeutronGraph op. The single Reshape we keep sits after the last convolution, where it can cut nothing, and only relabels the 64 pooled numbers for the Dense layer.
inputs = keras.Input(shape=(1, window, channels), name="signal")
The window enters as one row of 128 values. Below it is drawn twice: as the curve you know, and as the one-row image every following layer figure uses. Blue is negative, red is positive.
x = layers.Conv2D(16, (1, 7), strides=(1, 2), padding="same", activation="relu")(inputs)
Conv2D: a kernel of 7 numbers slides along the row. At every second position it multiplies the 7 samples under it with its 7 weights and sums them. One kernel gives one output row of 64 values. 16 kernels give 16 rows: the 16 channels. ReLU then clips negative sums to zero.
Output: 64 time steps × 16 channels. Parameters: 7 × 16 weights + 16 biases = 128. The vertical stripes in the fault window are the bursts, found by kernels that match a sharp edge.
model.layers[1].get_weights()[0].shape # (1, 7, 1, 16)
These are the learned weights of the first layer, seven bars per kernel. Some look like a piece of a sine, some like a difference of neighbours (an edge detector), some like a bump. Nobody designed them. Training moved them there in 15 epochs.
x = layers.Conv2D(32, (1, 5), strides=(1, 2), padding="same", activation="relu")(x)
Same operation, but each kernel now reads all 16 channels at once: 5 positions × 16 channels = 80 weights per kernel, 32 kernels. It combines the edge and sine detectors of layer 1 into shapes. 5 steps of the half-speed axis cover 10 ms of the original signal on top of the 7 ms each input already saw.
Output: 32 × 32. Parameters 5 × 16 × 32 + 32 = 2 592. The stripes are sparser: fewer channels react, each more specific.
x = layers.Conv2D(64, (1, 3), strides=(1, 2), padding="same", activation="relu")(x)
64 kernels, 3 wide, reading 32 channels: 96 weights each, 6 208 parameters, two thirds of the network. One output value here depends on 7 + 4·2 + 2·4 = 23 samples = 23 ms of the original signal. Bursts come every 7 to 11 ms, so one value can see two or three of them. That is the difference between "one loud spike" and "a train of rings".
Output: 16 × 64. Switch the class at the top right: each class lights a different set of channels.
x = layers.AveragePooling2D(pool_size=(1, window // 8))(x) # (1, 16)
AveragePooling2D: for each of the 64 channels, take the mean of its 16 time steps. 16 × 64 becomes 1 × 64. The result answers "how strongly did detector k fire over the whole window", and where in the window no longer matters. A fault at 20 ms is the same fault as at 90 ms.
No parameters. Fixed size (1, 16) instead of GlobalAveragePooling2D because the global variant lowers to a Mean op the NPU converter maps worse.
x = layers.Reshape((64,))(x) # (1, 1, 1, 64) → (1, 64)
Reshape computes nothing. The same 64 numbers from the previous figure are re-labelled from a 4-axis tensor to a flat vector, because a Dense layer reads vectors. Zero parameters, zero cost.
outputs = layers.Dense(num_classes, name="logits")(x) # 3
Dense: every one of the 64 inputs is connected to every one of the 3 outputs with its own weight. Three weighted sums plus three biases: 64 × 3 + 3 = 195 parameters. The three outputs are the logits, one raw score per class. The largest wins.
Left: the logits for the selected window. Right: their softmax, which the board computes in numpy after the model. No softmax is in the graph.
model.get_layer("logits").get_weights()[0].shape # (64, 3)
The 192 weights of the last layer as an image: one row per class, one column per pooled channel. Red means "this channel votes for this class", blue "against". Compare with the pooled bars two steps back: the channels that were high for bearing_fault are the red cells in the bottom row.
Press space six times. One window walks through the network. Change the class and watch a different path light up.
Quantise, convert, run. Three gates against three silent failures.
# 1. rebuild with a fixed batch axis, copy the trained weights in fixed_input = keras.Input(batch_shape=(1, height, window, channels), dtype="float32", name="signal") inference_model = keras.models.clone_model(model, input_tensors=fixed_input) inference_model.set_weights(model.get_weights()) # 2. calibrate on 300 real windows, emit int8 in and out def representative_data(X, count): def generator(): for window in X[:count]: yield [window[np.newaxis, ...].astype(np.float32)] return generator converter = tf.lite.TFLiteConverter.from_keras_model(inference_model) converter.optimizations = [tf.lite.Optimize.DEFAULT] converter.representative_dataset = representative_data(X, 300) converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8] converter.inference_input_type = tf.int8 converter.inference_output_type = tf.int8 open("model_int8.tflite", "wb").write(converter.convert())
The NPU computes with 8-bit integers, −128 to 127. Every tensor gets a scale and a zero point: real = (int8 − zero) × scale.
batch_shape=(1, …) is the whole difference.neutron_converter --input model_int8.tflite --target imx95 \
--output model_neutron.tflite 2>&1 | tee "$LOG"
# read the ratio out of the log ...
RATIO="$(sed -n 's/.*Operator conversion ratio.*=[[:space:]]*\([0-9.eE+-]*\).*/\1/p' "$LOG" | tail -n 1)"
# ... and abort hard below the threshold. The converter itself never does.
if awk -v r="$RATIO" -v t="${THRESHOLD:-0.80}" 'BEGIN { exit !(r + 0 < t + 0) }'; then
echo "ERROR: ratio ${RATIO} is below the threshold."
echo "The file exists and is valid - it would run on the board, but mostly on the CPU."
exit 3
fi
echo "Gate A passed."
Measured for this model on 2026-09-19: Operator conversion ratio = 9 / 9 = 1
.tflite handed to the NPU delegate raises no error. It runs on the CPU. So the model goes through the offline neutron_converter, and that tool exits with code 0 even when it mapped zero operations. NXP TechSupport: "the converter silently falls back to CPU execution."eiq-neutron-sdk==3.1.2, pinned to the board's BSP). On macOS the script builds a Docker container and runs it there.NeutronGraph. Only libneutron_delegate.so can resolve it. Without the delegate the file does not load at all. Ship both files.from tflite_runtime.interpreter import Interpreter, load_delegate
delegates = [load_delegate("/usr/lib/libneutron_delegate.so")]
interpreter = Interpreter(model_path="model_neutron.tflite",
experimental_delegates=delegates,
num_threads=1)
interpreter.allocate_tensors()
The delegate prints this on load. This line is Gate B:
INFO: NeutronDelegate delegate: 1 nodes delegated out of 1 nodes with 1 partitions.
python3 3.13, tflite_runtime 2.19, the delegate library, /dev/neutron0. Nothing to install.NeutronGraph node.0 nodes delegated out of 31 nodes. Reproduced live with an unconverted MobileNet. It runs, the results are right, and everything is on the CPU.def classify(self, window):
value = window[np.newaxis, ...].astype(np.float32) # (1, 1, 128, 1)
if self._input["dtype"] == np.int8:
scale, zero = self._input["quantization"]
value = np.clip(np.round(value / scale + zero), -128, 127).astype(np.int8)
self.interpreter.set_tensor(self._input["index"], value)
self.interpreter.invoke() # the NPU
raw = self.interpreter.get_tensor(self._output["index"])[0].astype(np.float32)
if self._output["dtype"] == np.int8:
scale, zero = self._output["quantization"]
raw = (raw - zero) * scale # logits, float again
shifted = np.exp(raw - raw.max()) # softmax, on the CPU
scores = shifted / shifted.sum()
return int(np.argmax(raw)), [float(s) for s in scores]
invoke() runs the one NeutronGraph op on the NPU. All six layers of chapter 3, in one call.argmax alone gives the class. The softmax only makes the displayed score.Measured end to end from a live stream: p50 1.427 ms, p95 1.684 ms per window against a 32 ms budget.
/sys/class/remoteproc/remoteproc0/state before and after. offline right after a run proves the NPU computed nothing. running proves only that the delegate reached the driver. Gate B says how much moved.| Configuration | Parameters | CPU median | NPU median | Factor |
|---|---|---|---|---|
--window 128 --width 1 (this deck) | 9 123 | 0.042 ms | 0.075 ms | 0.6× |
--window 1024 --width 4 | 140 931 | 1.309 ms | 0.119 ms | 11.0× |
The first row is the important one. All three gates green, the NPU demonstrably computed, and it was still slower than the CPU.
The second row explains it: NPU time grew from 0.075 to 0.119 ms while CPU time exploded 31×. There is a fixed per-call charge of under 0.1 ms. Only above that does throughput matter.
--window and --width are the two knobs that make the model big enough.All code from seminare/siemens-schulung at 4b44b51. Figures from a run of steps 1 and 2 on 2026-09-21. Board numbers from the example's README.