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 层比比看
咱们用同一个数据集,分别用 MyDenseV2 和 tf.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 总结一下
今天咱们干了三件事:
- 手写了一个全连接层,理解了
build和call的协作方式。 - 用真实数据训练了它,验证了梯度下降能正常工作。
- 加了进阶功能,让层更灵活、更健壮。
说实话,全连接层是所有自定义层的起点。你把这个搞懂了,后面什么卷积层、循环层、注意力层,思路都是一样的——继承 Layer,重写 build 和 call,必要时加上 get_config。
下一章,咱们会玩点更刺激的:自定义训练循环。到时候你会看到,当你不满足于 model.fit 时,TensorFlow 还能给你多大的自由度。
嗯,今天就到这儿。代码都在上面了,建议你亲手敲一遍。遇到问题别怕,调试自定义层本身就是最好的学习方式。