Custom layers, losses and metrics
When the built-in parts stop fitting your problem, Keras lets you write your own layer, your own definition of wrong, and your own scorecard.
- 8 min read
- 3 reading levels
- Published
Read these first
On this page 5
One lesson, three depths. Pick the one that fits you today — you can switch any time.
Beginner — No maths. Plain English.
Keras ships ready-made layers, losses and metrics — and clean extension points for writing your own when the ready-made ones do not fit.
Think of furnishing a house. Most rooms are fine with shop-bought furniture: standard beds, standard cupboards. But one room has a slanted ceiling, and no shop sells a cupboard for it. You call a carpenter and describe the exact shape you need.
Custom layers, losses and metrics are the carpenter option. Same house, same rooms — one part built to your measurements.
Why they exist
Three different things go custom, for three different reasons:
- A custom layer — when the transformation you need between inputs and outputs is not in the catalogue. Maybe your data needs a learned scaling no standard layer provides.
- A custom loss — when the built-in definition of "wrong" does not match the real cost of being wrong. A shop that under-stocks loses a sale; over-stocking loses only shelf space. Those mistakes are not equal, but standard losses treat them equally.
- A custom metric — when the score humans care about is not accuracy. "How often were we within 2 rupees of the real price?" is a business question no built-in metric answers.
How it works
custom layer: inputs → [ your maths + learnable knobs ] → outputs
custom loss: (truth, prediction) → one number: "how bad"
custom metric: (truth, prediction) → running score across batchesEach is a small recipe you hand to Keras. The layer joins the model. The loss and metric plug into compile, exactly where the built-in names went.
A real example you have seen
Food delivery apps predict arrival times. Telling you 30 minutes and arriving in 45 makes you angry; telling you 45 and arriving in 30 makes you pleased. The two errors are the same size and completely different in cost. Teams building these systems write a loss that punishes late-side mistakes harder — a custom definition of wrong.
Remember this
- Layer = custom transformation with learnable values inside the model.
- Loss = custom definition of "how wrong", used for learning.
- Metric = custom scorecard for humans, not used for learning.
What to learn next
- Overriding train_step — customising not the parts, but the training loop itself.
- Custom loss functions in PyTorch — the same ideas in the other framework.
- Loss functions — the standard catalogue you are extending.
Developer — Code and libraries.
Setup
pip install tensorflow numpyOutputs verified with TensorFlow 2.21 (Keras 3.12), CPU, seed 0. Learned weight values will drift slightly across versions.
A custom layer and a custom loss, trained
import numpy as np
import keras
from keras import layers, ops
keras.utils.set_random_seed(0)
class ScaledDense(layers.Layer):
"""A Dense layer that also learns one overall output scale."""
def __init__(self, units, **kwargs):
super().__init__(**kwargs)
self.units = units
def build(self, input_shape):
# build() runs once, when the layer first sees its input shape
self.w = self.add_weight(shape=(input_shape[-1], self.units),
initializer="glorot_uniform", trainable=True)
self.b = self.add_weight(shape=(self.units,),
initializer="zeros", trainable=True)
self.scale = self.add_weight(shape=(), initializer="ones",
trainable=True)
def call(self, inputs):
return self.scale * (ops.matmul(inputs, self.w) + self.b)
def underprediction_loss(y_true, y_pred):
"""Squared error, but predicting too LOW costs 3x more than too high."""
diff = y_true - y_pred
weight = ops.where(diff > 0, 3.0, 1.0)
return ops.mean(weight * ops.square(diff))
# Demand forecasting: yesterday's sales -> today's sales
x = np.array([[10.], [20.], [30.], [40.], [50.], [60.]], dtype="float32")
y = np.array([[12.], [22.], [33.], [41.], [55.], [63.]], dtype="float32")
model = keras.Sequential([keras.Input(shape=(1,)), ScaledDense(1)])
model.compile(optimizer=keras.optimizers.Adam(0.5), loss=underprediction_loss)
model.fit(x, y, epochs=300, verbose=0)
preds = model.predict(x, verbose=0)
print("weights learned:", [round(float(v), 3) for v in
[model.layers[0].w[0, 0], model.layers[0].b[0],
model.layers[0].scale]])
print("true 33 predicted as:", round(float(preds[2, 0]), 1))weights learned: [0.909, 1.201, 1.155] true 33 predicted as: 32.9
The walkthrough
__init__ vs build vs call — the three-part contract of every layer. __init__ stores configuration (how many units). build creates weights, and runs only once, the first time data arrives — that is how the layer knows input_shape without you declaring it. call is the forward computation, run on every batch.
self.add_weight(...) registers a trainable value. Registration is the point: anything added this way is found by the optimizer, saved with the model, and listed in summary(). A plain attribute holding a tensor would train nothing.
ops.matmul, ops.where, ops.mean come from keras.ops — the Keras 3 operations namespace that works on every backend. Writing tf. inside layers still works on the TensorFlow backend, but locks the layer to it.
The loss is a plain function with the fixed signature (y_true, y_pred) → scalar-per-sample. The asymmetry lives in one where: under-predictions (truth above prediction) get weight 3. Passing the function object — not a string — to compile is all the registration needed.
A custom metric
Metrics accumulate across batches, so they are classes with state, not plain functions:
import numpy as np
import keras
from keras import ops
class WithinTolerance(keras.metrics.Metric):
"""Fraction of predictions landing within +/- tolerance of the truth."""
def __init__(self, tolerance=2.0, name="within_tolerance", **kwargs):
super().__init__(name=name, **kwargs)
self.tolerance = tolerance
self.hits = self.add_weight(shape=(), initializer="zeros", name="hits")
self.total = self.add_weight(shape=(), initializer="zeros", name="total")
def update_state(self, y_true, y_pred, sample_weight=None):
close = ops.abs(y_true - y_pred) <= self.tolerance
self.hits.assign_add(ops.sum(ops.cast(close, "float32")))
self.total.assign_add(ops.cast(ops.size(y_true), "float32"))
def result(self):
return self.hits / self.total
metric = WithinTolerance(tolerance=2.0)
metric.update_state(np.array([12., 22., 33.]), np.array([11., 30., 32.5]))
print("within 2 units:", round(float(metric.result()), 3))within 2 units: 0.667
update_state runs per batch and adds to counters; result turns the counters into the final score. Passing metrics=[WithinTolerance()] to compile makes it appear in fit logs like any built-in.
Common mistakes
Creating weights in call. New weights every batch, nothing trains, and Keras raises an error about variables created inside a traced function. Weights belong in build (or __init__ if shapes are known).
A loss with flipped arguments. The signature is (y_true, y_pred) in that order. Swap them in an asymmetric loss and you punish the opposite mistake. Symmetric losses hide the bug entirely — test your loss on hand-made numbers where the two orders differ.
Using a metric as a loss. Metrics like the one above use comparisons, which have no useful gradient. Training on them leaves the model frozen. Losses must be smooth; metrics may be blunt.
Forgetting get_config before saving. A model containing custom pieces reloads only if each piece can describe its constructor arguments. Add get_config() returning {**super().get_config(), "units": self.units} — or reloading raises. Details in saving and loading Keras models.
Try it yourself
Flip the loss: make over-prediction cost 3x instead. Retrain and compare the prediction for the 40. input against the run above — the model should now aim low. One changed line, visible behaviour change.
What to learn next
- Overriding train_step — customising not the parts, but the training loop itself.
- Custom loss functions in PyTorch — the same ideas in the other framework.
- Loss functions — the standard catalogue you are extending.
Researcher — Mathematics and papers.
Asymmetric losses, formally
The custom loss above is a special case of the linex/weighted-quadratic family. With residual $r = y - \hat{y}$:
$$ \ell(r) = w(r)\, r^2, \qquad w(r) = \begin{cases} c & r > 0 \ 1 & r \le 0 \end{cases} $$
Symbols: $y$ — the truth; $\hat{y}$ — the prediction; $r$ — the residual, positive when the model under-predicts; $c$ — the asymmetry factor (3 in the code).
The minimiser of expected loss shifts away from the conditional mean: for squared loss the optimal prediction is $\mathbb{E}[y|x]$, but under asymmetric weighting it moves toward the costly side. In the limit of the pinball (quantile) loss $\ell_\tau(r) = r(\tau - \mathbf{1}[r<0])$, the optimal prediction is exactly the $\tau$-quantile of $y|x$ — the principled version of "aim high" (Koenker and Bassett 1978, Regression quantiles). If your real goal is a quantile, use pinball loss rather than tuning $c$ by feel.
The gradient requirement: backpropagation needs $\partial \ell / \partial \hat{y}$ to exist and be non-zero almost everywhere. The where above is piecewise-smooth — fine. Hard 0/1 comparisons (accuracy, the tolerance metric) have zero gradient almost everywhere, which is the formal reason metrics cannot serve as losses; the usable trick is a smooth surrogate, the same argument that replaced 0-1 classification loss with crossentropy (Bartlett, Jordan, McAuliffe 2006, Convexity, classification, and risk bounds).
Layer lifecycle cost
Deferred building (build on first call) exists so layers compose without shape declarations — shape inference propagates through the model graph once, at first trace, costing O(number of layers) and nothing per step thereafter. The registration machinery (add_weight) is what ties into the pytree/variable tracking that optimizers traverse; unregistered tensors are invisible to model.trainable_variables, which is the single mechanism behind the "nothing trains" failure mode.
References
- Koenker and Bassett (1978), Regression quantiles.
- Varian (1975), A Bayesian approach to real estate assessment — origin of the linex asymmetric loss.
- Bartlett, Jordan, McAuliffe (2006), Convexity, classification, and risk bounds.
What to learn next
- Overriding train_step — customising not the parts, but the training loop itself.
- Custom loss functions in PyTorch — the same ideas in the other framework.
- Loss functions — the standard catalogue you are extending.