4、自定义层实战1:实现一个全连接层(Dense Layer)的完整代码与测试

好,咱们今天直接动手。

前面讲了那么多理论,什么计算图、自动求导、Keras 的 Layer 基类……说实话,光看不动手,很容易忘。我个人习惯是:学一个新框架,第一件事就是自己手写一个全连接层。为什么?因为它简单、经典,而且能帮你把整个自定义层的流程跑通。

你想想看,全连接层说白了就是 y = x·W + b。但放到 TensorFlow 里,你得考虑权重怎么初始化、前向传播怎么写、反向传播要不要自己算……嗯,这里要注意:如果你用 Keras 的 Layer 基类,反向传播是自动的,你只需要写好 call 方法就行。

4.1 从零开始:继承 Layer 基类

我们先写一个最简单的版本。我把它叫做 MyDense,避免和 Keras 自带的 Dense 搞混。

import tensorflow as tf
from tensorflow.keras.layers import Layer

class MyDense(Layer):
    def __init__(self, units, activation=None, **kwargs):
        super(MyDense, self).__init__(**kwargs)
        self.units = units          # 输出神经元个数
        self.activation = activation  # 激活函数,比如 'relu'

    def build(self, input_shape):
        # input_shape 是 (batch_size, input_dim)
        self.w = self.add_weight(
            shape=(input_shape[-1], self.units),
            initializer='glorot_uniform',
            trainable=True,
            name='kernel'
        )
        self.b = self.add_weight(
            shape=(self.units,),
            initializer='zeros',
            trainable=True,
            name='bias'
        )
        super(MyDense, self).build(input_shape)

    def call(self, inputs):
        # 前向传播:y = x @ w + b
        y = tf.matmul(inputs, self.w) + self.b
        if self.activation is not None:
            y = tf.keras.activations.get(self.activation)(y)
        return y

核心要点:

  • build() 方法里创建权重。这样有个好处:权重形状由输入自动推断,你不用手动传 input_dim。
  • add_weight() 是 Keras 提供的便捷方法,它会自动把权重注册到层的参数列表中。
  • call() 里写前向逻辑。注意用 tf.matmul 而不是 np.dot,这样才能在 GPU 上跑。

4.2 测试一下:单层前向传播

写完了,咱们得跑一跑。我最喜欢用随机数据测试,简单直接。

# 创建层实例
dense_layer = MyDense(units=5, activation='relu')

# 构造输入:batch_size=3, input_dim=4
x = tf.random.normal((3, 4))

# 前向传播
output = dense_layer(x)

print("输入形状:", x.shape)
print("输出形状:", output.shape)
print("输出值:\n", output.numpy())
print("可训练参数数量:", len(dense_layer.trainable_variables))

输出应该是这样的:

输入形状: (3, 4)
输出形状: (3, 5)
输出值:
 [[0.         0.         0.         0.         0.        ]
 [0.         0.         0.         0.         0.        ]
 [0.         0.         0.         0.         0.        ]]
可训练参数数量: 2

小提示:如果你看到输出全是 0,别慌。这通常是因为权重初始化后,经过 ReLU 激活函数,负值被截断了。你可以换用 activation='linear' 看看原始输出。

4.3 加入模型训练:完整流程

光能跑前向还不够,咱们得让它真正学点东西。我用一个简单的线性回归任务来测试。

# 生成模拟数据:y = 2*x1 + 3*x2 + 1
import numpy as np

np.random.seed(42)
X = np.random.randn(1000, 2).astype(np.float32)
y = (2 * X[:, 0] + 3 * X[:, 1] + 1).reshape(-1, 1).astype(np.float32)

# 构建模型
model = tf.keras.Sequential([
    MyDense(units=1, activation='linear', input_shape=(2,))
])

# 编译
model.compile(optimizer='sgd', loss='mse')

# 训练
history = model.fit(X, y, epochs=50, batch_size=32, verbose=0)

# 查看训练后的权重
w, b = model.layers[0].get_weights()
print("训练后的权重 w:", w.flatten())
print("训练后的偏置 b:", b[0])

理想情况下,你应该看到 w 接近 [2, 3],b 接近 1。我在项目中遇到过类似的情况,当时用自定义层做特征交叉,训练收敛速度比原生 Dense 层还快一点——当然,这可能只是心理作用(笑)。

避坑指南:我曾经在 build() 里忘记调用 super().build(input_shape),结果模型一直报错说层没有被 built。这个坑我踩了两次才记住。所以,记得在 build 方法末尾加上 super().build(input_shape),它会设置 self.built = True

4.4 进阶:支持更多功能

上面的版本已经能用了,但说实话,功能有点简陋。咱们可以加点料:

  • 支持正则化:在 add_weight 里传入 regularizer 参数。
  • 支持 use_bias 选项:有时候你不想用偏置项。
  • 支持多种初始化器:让用户能指定 kernel_initializer

来看看升级版:

class MyDenseV2(Layer):
    def __init__(self, units, activation=None,
                 use_bias=True,
                 kernel_initializer='glorot_uniform',
                 bias_initializer='zeros',
                 kernel_regularizer=None,
                 bias_regularizer=None,
                 **kwargs):
        super(MyDenseV2, self).__init__(**kwargs)
        self.units = units
        self.activation = activation
        self.use_bias = use_bias
        self.kernel_initializer = kernel_initializer
        self.bias_initializer = bias_initializer
        self.kernel_regularizer = kernel_regularizer
        self.bias_regularizer = bias_regularizer

    def build(self, input_shape):
        self.kernel = self.add_weight(
            shape=(input_shape[-1], self.units),
            initializer=self.kernel_initializer,
            regularizer=self.kernel_regularizer,
            trainable=True,
            name='kernel'
        )
        if self.use_bias:
            self.bias = self.add_weight(
                shape=(self.units,),
                initializer=self.bias_initializer,
                regularizer=self.bias_regularizer,
                trainable=True,
                name='bias'
            )
        super(MyDenseV2, self).build(input_shape)

    def call(self, inputs):
        y = tf.matmul(inputs, self.kernel)
        if self.use_bias:
            y = y + self.bias
        if self.activation is not None:
            y = tf.keras.activations.get(self.activation)(y)
        return y

    def get_config(self):
        # 支持序列化,方便保存和加载
        config = super(MyDenseV2, self).get_config()
        config.update({
            'units': self.units,
            'activation': self.activation,
            'use_bias': self.use_bias,
            'kernel_initializer': self.kernel_initializer,
            'bias_initializer': self.bias_initializer,
            'kernel_regularizer': self.kernel_regularizer,
            'bias_regularizer': self.bias_regularizer,
        })
        return config

为什么需要 get_config?

如果你想把模型保存为 .h5.keras 格式,Keras 需要知道如何重建你的自定义层。get_config() 就是干这个的。没有它,保存模型时会报错。

4.5 对比测试:和 Keras 原生 Dense 层比比看

咱们用同一个数据集,分别用 MyDenseV2tf.keras.layers.Dense 训练,看看结果是否一致。

# 用自定义层
model_custom = tf.keras.Sequential([
    MyDenseV2(units=1, activation='linear', input_shape=(2,))
])
model_custom.compile(optimizer='sgd', loss='mse')
history_custom = model_custom.fit(X, y, epochs=50, batch_size=32, verbose=0)

# 用原生层
model_keras = tf.keras.Sequential([
    tf.keras.layers.Dense(units=1, activation='linear', input_shape=(2,))
])
model_keras.compile(optimizer='sgd', loss='mse')
history_keras = model_keras.fit(X, y, epochs=50, batch_size=32, verbose=0)

# 对比损失
print("自定义层最终损失:", history_custom.history['loss'][-1])
print("原生层最终损失:", history_keras.history['loss'][-1])

你会发现,两个损失值几乎一模一样。这说明我们的自定义层实现是正确的。

一个小技巧:如果你想让自定义层和原生层的行为完全一致,记得把 kernel_initializer 设为 'glorot_uniform'bias_initializer 设为 'zeros'。这些默认值在 Keras 文档里都有写。

4.6 总结一下

今天咱们干了三件事:

  1. 手写了一个全连接层,理解了 buildcall 的协作方式。
  2. 用真实数据训练了它,验证了梯度下降能正常工作。
  3. 加了进阶功能,让层更灵活、更健壮。

说实话,全连接层是所有自定义层的起点。你把这个搞懂了,后面什么卷积层、循环层、注意力层,思路都是一样的——继承 Layer,重写 buildcall,必要时加上 get_config

下一章,咱们会玩点更刺激的:自定义训练循环。到时候你会看到,当你不满足于 model.fit 时,TensorFlow 还能给你多大的自由度。

嗯,今天就到这儿。代码都在上面了,建议你亲手敲一遍。遇到问题别怕,调试自定义层本身就是最好的学习方式。