手撕经典算法 #4 经典函数篇

本文最后更新于:2025年3月19日 晚上

本文对深度学习中经典的函数进行了简单的实现和注释。包括:

  • 损失函数(MSE、CE、BCE、KL、Focal)
  • 激活函数(Sigmoid、Tanh、ReLU、Leaky ReLU、ELU、Swish、GeLU、SwiGLU、Softmax)
  • 指标计算(PPL、ROUGE、BLEU)

损失函数

MSE Loss

均方误差(Mean Squared Error,MSE)衡量预测值与真实值的平方差均值,是回归任务中最常用的损失函数:

\[ L = \frac{1}{N}\sum_{i=1}^N (y_i - \hat{y}_i)^2 \]

其中 \(y_i\) 为真实值,\(\hat{y}_i\) 为预测值。其梯度计算为 \(\frac{\partial L}{\partial \hat{y}_i} = \frac{2}{n}(\hat{y}_i - y_i)\),具有凸函数的良好优化特性,可导且处处平滑,适合梯度下降。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import numpy as np

def mse_loss(y_true, y_pred):
"""
计算均方误差损失
:param y_true: 真实值数组,形状 (n_samples, )
:param y_pred: 预测值数组,形状 (n_samples, )
:return: 标量损失值
"""
squared_error = (y_true - y_pred) ** 2
return np.mean(squared_error)

# 示例用法
y_true = np.array([2.0, 4.0, 5.0])
y_pred = np.array([1.5, 3.8, 4.9])
print(f"MSE Loss: {mse_loss(y_true, y_pred):.4f}") # 输出 MSE Loss: 0.0867

CE Loss

交叉熵(Cross Entropy)衡量两个概率分布间的差异,常用于多分类任务。给定真实分布 \(P\) 和预测分布 \(Q\)

\[ H(P, Q) = -\sum_{i=1}^N P(x_i) \log Q(x_i) \]

在分类任务中,真实标签常采用 one-hot 编码,公式简化为:

\[ L = -\frac{1}{N}\sum_{i=1}^N \sum_{i=1}^C y_i \log \hat{y}_i \]

其中 \(C\) 为类别总数,\(\hat{y}_i\) 需经过 Softmax 归一化

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import numpy as np

def cross_entropy(y_true, y_pred):
"""
计算交叉熵损失(需配合 Softmax 使用),带数值稳定处理
:param y_true: one-hot 编码的真实标签,形状 (n_samples, n_classes)
:param y_pred: 模型输出的 logits,形状 (n_samples, n_classes)
:return: 标量损失值
"""
# 数值稳定处理:减去最大值防止指数爆炸
exps = np.exp(y_pred - np.max(y_pred, axis=1, keepdims=True))
softmax_output = exps / np.sum(exps, axis=1, keepdims=True)

# 避免 log(0) 导致数值问题
epsilon = 1e-7
clipped = np.clip(softmax_output, epsilon, 1 - epsilon)

# 只计算真实类别对应的损失
n_samples = y_true.shape[0]
log_likelihood = -np.log(clipped[range(n_samples), y_true.argmax(axis=1)])
return np.mean(log_likelihood)

# 示例用法(三分类问题)
y_true = np.array([[1,0,0], [0,1,0]]) # one-hot 编码
y_pred = np.array([[2.0, 1.0, 0.1], [0.5, 3.0, 0.2]])
print(f"CrossEntropy Loss: {cross_entropy(y_true, y_pred):.4f}") # 输出 0.3184

BCE Loss

二元交叉熵(Binary Cross Entropy)处理的是二分类问题,其归一化的方式从 Softmax 替换为 Sigmoid,并且每个类别的概率独立计算(不像交叉熵仅计算真实类别的损失): \[ L = -\frac{1}{N}\sum_{i=1}^N \left[ y_i \cdot \log(\sigma(x_i)) + (1-y_i) \cdot \log(1-\sigma(x_i)) \right] \] 此外,BCE 也可以用于多分类多标签任务,此时需要将每个类别看作为 0 或 1 的二分类问题。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import numpy as np

def binary_cross_entropy(y_true, y_pred):
"""
计算二元交叉熵损失(需配合 Sigmoid 使用)
:param y_true: 二分类的真实标签(0 或 1),形状 (n_samples, n_classes)
:param y_pred: 模型输出的 logits,形状 (n_samples, n_classes)
:return: 标量损失值
"""
# 应用 Sigmoid 将 logits 转换为概率
sigmoid_output = 1 / (1 + np.exp(-y_pred))

# 避免 log(0) 导致的数值问题
epsilon = 1e-7
clipped = np.clip(sigmoid_output, epsilon, 1 - epsilon)

# 计算每个样本每个类别的损失
loss_per_element = - (y_true * np.log(clipped) + (1 - y_true) * np.log(1 - clipped))

# 对所有元素取平均损失
return np.mean(loss_per_element)

# 示例用法(两个样本,三分类多标签问题)
y_true = np.array([[1, 0, 1], [0, 1, 0]]) # 多标签真实值
y_pred = np.array([[2.0, 1.0, -1.0], [0.5, 3.0, -0.5]])
print(f"BCE Loss: {binary_cross_entropy(y_true, y_pred):.4f}") # 输出 0.1955

KL 散度

KL 散度(Kullback-Leibler Divergence)衡量两个概率分布 \(P\)\(Q\) 的差异程度:

\[ D_{KL}(P \parallel Q) = \sum_{i=1}^N P(x_i) \log \frac{P(x_i)}{Q(x_i)} \]

其性质包括:

  • 非对称性:\(D_{KL}(P \parallel Q) \neq D_{KL}(Q \parallel P)\)
  • 非负性:\(D_{KL} \geq 0\),当且仅当 P=Q 时等于 0
  • 与交叉熵的关系:\(D_{KL}(P \parallel Q) = H(P, Q) - H(P)\)

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def kl_divergence(p, q):
"""
计算两个离散分布的KL散度
:param p: 真实概率分布,形状 (n_classes, )
:param q: 预测概率分布,形状 (n_classes, )
:return: 标量散度值
"""
# 过滤零元素避免数值问题
mask = (p != 0)
p = p[mask]
q = q[mask]
return np.sum(p * np.log(p / q))

# 示例用法(概率分布差异对比)
P = np.array([0.4, 0.6])
Q1 = np.array([0.4, 0.6])
Q2 = np.array([0.5, 0.5])

print(f"KL(P||Q1): {kl_divergence(P, Q1):.4f}") # 输出 0.0000
print(f"KL(P||Q2): {kl_divergence(P, Q2):.4f}") # 输出 0.0204

Focal 损失

通过调节难易样本权重解决数据倾斜问题,适用于长尾分布场景的二分类问题

\[ FL = -\alpha_t (1-p_t)^\gamma \log(p_t) \]

其中:

  • \(p_t\) 是模型对真实类别的预测概率: \[ p_t = \begin{cases} p & \text{正样本} \\ 1-p & \text{负样本} \end{cases} \]

  • \(\alpha \in [0,1]\):类别平衡因子,通常为稀有类别分配更高权重(如 \(\alpha=0.25\))。

  • \(\gamma \geq 0\): 困难样本聚焦参数,调整难易样本的权重比例(通常 $ $ )。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def focal_loss(y_true, y_pred, alpha=0.25, gamma=2.0):
"""
计算二分类Focal Loss
:param y_true: 真实标签 (n_samples, )
:param y_pred: 预测概率 (n_samples, )
:param alpha: 类别平衡因子
:param gamma: 困难样本聚焦参数
:return: 标量损失值
"""
epsilon = 1e-7
y_pred = np.clip(y_pred, epsilon, 1 - epsilon)
p_t = y_true * y_pred + (1 - y_true) * (1 - y_pred)
alpha_factor = y_true * alpha + (1 - y_true) * (1 - alpha)
loss = -alpha_factor * (1 - p_t) ** gamma * np.log(p_t)
return np.mean(loss)

# 示例用法(处理文本分类中的长尾分布)
y_true = np.array([1, 0, 1, 1, 0]) # 多数类别为1
y_pred = np.array([0.9, 0.2, 0.8, 0.7, 0.1])
print(f"Focal Loss: {focal_loss(y_true, y_pred):.4f}") # 输出约0.032

激活函数

Sigmoid

输出区间 (0,1),符合概率分布特性,常用于二分类输出层。表达式为: \[ \sigma(x) = \frac{1}{1+e^{-x}} \] 导数: \[ \sigma'(x) = \sigma(x)(1-\sigma(x)) \] 缺点:

  • 输入较大或较小时候梯度接近于 0,容易导致梯度消失(且导数最大值为 0.25,更新效率不高);
  • 函数输出不是以 0 为中心的,梯度可能就会向特定方向移动,从而降低权重更新的效率;
  • 执行指数运算,计算机运行得较慢,比较消耗计算资源。

代码如下:

1
2
3
def sigmoid(x):
x = np.clip(x, -50, 50) # 防止数值溢出
return 1 / (1 + np.exp(-x))

Tanh

输出区间 \((-1,1)\),以零为重心,缓解梯度偏移问题,表达式为: \[ \tanh(x) = \frac{e^x - e^{-x}}{e^x + e^{-x}} \] 导数: \[ \tanh'(x) = 1 - \tanh^2(x) \] 缺点:

  • 仍然存在梯度饱和的问题:但 \(x\) 进入饱和区(saturation region)时,其导数值趋近于零,最终无法有效更新网络参数;
  • 依然进行的是指数运算,比较消耗计算资源。

代码如下:

1
2
def tanh(x):
return np.tanh(x) # 内置优化实现比手动计算更稳定

ReLU

表达式为: \[ \text{ReLU}(x) = \max(0, x) \] 导数: \[ \text{ReLU}'(x) = \begin{cases} 1 & x > 0 \\ 0 & x \leq 0 \end{cases} \] 优点:

  • ReLU 解决了梯度消失的问题,当输入值为正时,神经元不会饱和(梯度始终为 1);
  • 由于 ReLU 线性、非饱和的性质,在 SGD 中能够快速收敛;
  • 计算复杂度低,不需要进行指数运算。

缺点:

  • Dead ReLU 问题:负区间梯度归零,不再对任何数据有所响应,导致相应参数永远不会被更新;
  • 与 Sigmoid 一样,其输出不是以 0 为中心的。

代码如下:

1
2
def relu(x):
return np.maximum(0, x) # 简洁的向量化实现

Leaky ReLU

缓解神经元死亡问题(负区间保留小梯度 \(\alpha\),常见取值为 \(0.01-0.3\)),表达式为: \[ \text{LeakyReLU}(x) = \begin{cases} x & x > 0 \\ \alpha x & x \leq 0 \end{cases} \quad (\alpha \in (0,1)) \]

导数: \[ \text{LeakyReLU}'(x) = \begin{cases} 1 & x > 0 \\ \alpha & x \leq 0 \end{cases} \] 代码如下:

1
2
def leaky_relu(x, alpha=0.01):
return np.where(x > 0, x, alpha * x) # 条件表达式实现分支逻辑

ELU

ELU 采用比 ReLU 更平滑的过渡,保持负区间微小梯度(\(\alpha\) 控制信息保留程度)解决神经元死亡问题。最重要的是,可以控制激活函数的输出均值接近于零(假设输入分布为标准正态输入),使正常梯度更接近于单位自然梯度,从而加快学习速度。表达式为: \[ \text{ELU}(x) = \begin{cases} x & x > 0 \\ \alpha(e^x - 1) & x \leq 0 \end{cases} \]

导数: \[ \text{ELU}'(x) = \begin{cases} 1 & x > 0 \\ \text{ELU}(x) + \alpha & x \leq 0 \end{cases} \] 缺点:

  • ELU 在较小的输入下会饱和至负值,从而减少前向传播的变异和信息;
  • 计算的时需要计算指数,计算效率低。

代码如下:

1
2
def elu(x, alpha=1.0):
return np.where(x > 0, x, alpha * (np.exp(x) - 1)) # 负区间指数计算

Swish

Google Brain (2017) 提出的自门控的智能激活,结合了线性与非线性特性的激活函数: \[ \text{Swish}(x) = x \cdot \sigma(\beta x) \] 导数: \[ \text{Swish}'(x) = \text{Swish}(x) + \sigma(\beta x)(1 - \text{Swish}(x)) \] 优点: - 自适应门控机制(通过 sigmoid 调整 \(\beta\),Swish 可以模拟不同形状); - 处处平滑可微,在全体实数域上连续可导(优于 ReLU 系列,\(x=0\) 处不可导); - 在深层网络中表现优于 ReLU(Google 实验证明)。

代码如下:

1
2
def swish(x, beta=1.0):
return x * (1 / (1 + np.exp(-beta * x)))

GeLU

高斯误差线性单元(Gaussian Error Linear Unit)是一种结合了高斯分布特性的激活函数,旨在通过概率建模的方式平滑地调整神经元的激活状态,被 BERT、GPT 采用。其数学表达式为:

\[ \text{GeLU}(x) = x \cdot \Phi(x) \]

其中,\(\Phi(x)\) 是标准高斯分布的累积分布函数(CDF)。为了高效计算,常采用近似公式: \[ \text{GeLU}(x) \approx 0.5x\left(1 + \tanh\left(\sqrt{\frac{2}{\pi}}(x + 0.044715x^3)\right)\right) \]

优点:

  • 跟 Swish 长得非常像,都是平滑版的 ReLU(保留非线性同时可微分);
  • 通过概率权重调整激活强度,避免 ReLU 的神经元死亡现象(负值完全被抑制)。

代码如下:

1
2
3
def gelu(x):
# 使用近似公式
return 0.5 * x * (1 + np.tanh(np.sqrt(2/np.pi) * (x + 0.044715 * x**3)))

SwiGLU

SwiGLU 是一种结合了 Swish 激活函数和门控线性单元(GLU)的复合激活函数,近年来被广泛应用于大型语言模型(LLM)如 LLaMA、PaLM 等

GLU 的原始形式为: \[ \text{GLU}(x) = \sigma(W x + b) \otimes (Vx) \] 其中 \(\otimes\) 是逐元素乘法,\(\sigma\) 是 Sigmoid 函数,用于门控信息流。而 SwiGLU 将 GLU 中的 Sigmoid 替换为 Swish: \[ \text{SwiGLU}(x) = \text{Swish}(W x + b) \otimes (Vx) \]

在实际实现中,SwiGLU 通常被整合到前馈网络(FFN)中,传统的 FFN 可以记为: \[ \text{FFN}_\text{ReLU} = (\text{ReLU}(W_1 x+b))W_2 + c \] 而 SwiGLU 通过 \(W_1x\) 完成升维操作的同时,还会用 \(Vx\) 完成门控操作,最后再用 \(W_2\) 降维: \[ \text{FFN}_\text{SwiGLU} = (\text{Swish}(W_1 x + b) \otimes (Vx))W_2 + c \] 优点:

  • Swish 的连续梯度缓解了梯度消失问题,而门控机制进一步平衡了信息流,使深层网络训练更稳定;
  • 实验表明其计算效率优于 GeLU,且下游任务表现更好。

Llama 中的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
class LlamaMLP(nn.Module):
def __init__(
self,
hidden_size: int, # 4096
intermediate_size: int, # 11008
):
super().__init__()
self.up_proj = nn.Linear(hidden_size, intermediate_size, bias=True)
self.gate_proj = nn.Linear(hidden_size, intermediate_size, bias=False)
self.down_proj = nn.Linear(intermediate_size, hidden_size, bias=True)

def forward(self, x):
return self.down_proj(self.gate_proj(x) * self.up_proj(x))

Softmax

基本表达式为: \[ \text{Softmax}(x_i) = \frac{e^{x_i}}{\sum_{j=1}^n e^{x_j}} \] 在实际使用中,为了消除指数爆炸风险(原始值超过 20 时可能发生浮点溢出),通常会等价变形为: \[ \text{Softmax}(x_i) = \frac{e^{x_i - \text{max}(x)}}{\sum_{j=1}^n e^{x_j - \text{max}(x)}} \]

1
2
3
4
def softmax(x, eps=1e-8):
# x 的形状为 (n_samples, n_classes)
exps = np.exp(x - np.max(x, axis=1, keepdims=True))
return exps / np.sum(exps, axis=-1, keepdims=True)

指标计算

PPL

困惑度(Perplexity)是语言模型的核心评估指标,用于衡量模型对测试数据的预测能力。其本质是交叉熵的指数形式,可理解为模型在预测时面临的平均「选择困境」。 \[ PPL = \exp\left(-\frac{1}{N}\sum_{i=1}^N \log p(w_i|w_{<i})\right) \]

其中 \(N\) 为测试集词数,\(p(w_i|w_{<i})\) 是模型预测当前词的概率。PPL 值越低,说明模型预测越准确。

1
2
3
4
5
6
7
8
9
10
11
def calculate_ppl(log_probs):
"""
计算困惑度
:param log_probs: 模型输出的对数概率序列
:return: PPL值
"""
return np.exp(-np.mean(log_probs))

# 示例:假设模型对5个词的预测对数概率分别为-0.2, -0.3, -0.1, -0.4, -0.2
log_probs = [-0.2, -0.3, -0.1, -0.4, -0.2]
print(calculate_ppl(log_probs)) # 输出:1.284

ROUGE指标

自动摘要任务的黄金标准,主要变体:

  • ROUGE-N:基于 n-gram 重叠的召回率
  • ROUGE-L:基于最长公共子序列(LCS)

\[ ROUGE{\text-L} = \frac{(1+\beta^2)R_{\text{lcs}}P_{\text{lcs}}}{R_{\text{lcs}}+\beta^2 P_{\text{lcs}}} \]

其中 \(\beta\) 控制召回率权重,通常设为 1.2。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def rouge_l(reference, candidate):
"""
计算 ROUGE-L 分数
:param reference: 参考摘要(词列表)
:param candidate: 生成摘要(词列表)
:return: F1分数
"""
# LCS 长度计算(动态规划实现)
m, n = len(reference), len(candidate)
dp = [[0]*(n+1) for _ in range(m+1)]
for i in range(1, m+1):
for j in range(1, n+1):
if reference[i-1] == candidate[j-1]:
dp[i][j] = dp[i-1][j-1] + 1
else:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
lcs = dp[m][n]

precision = lcs / len(candidate)
recall = lcs / len(reference)
beta = 1.2
return ( (1 + beta**2) * precision * recall ) / ( recall + beta**2 * precision )

BLEU分数

机器翻译经典指标,基于修正 n-gram 精度和长度惩罚:

\[ BLEU = BP \cdot \exp\left(\sum_{n=1}^N w_n \log p_n\right) \]

其中 BP(Brevity Penalty)惩罚过短输出:

\[ BP = \begin{cases} 1 & \text{if } c > r \\ e^{(1-r/c)} & \text{otherwise} \end{cases} \]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
def bleu(references, candidate, weights=[0.25]*4):
"""
计算 BLEU 分数
:param references: 多个参考译文列表
:param candidate: 候选译文(词列表)
:param weights: n-gram 权重(默认 4-gram 平均)
:return: BLEU 分数(0-1)
"""
# 计算各n-gram精度
p_n = []
for n in range(1, len(weights)+1):
candidate_grams = [tuple(candidate[i:i+n]) for i in range(len(candidate)-n+1)]
refs_grams = [ [tuple(ref[i:i+n]) for i in range(len(ref)-n+1)] for ref in references]

count_clip = 0
for gram in set(candidate_grams):
max_ref_count = max([ref.count(gram) for ref in refs_grams])
count_clip += min(candidate_grams.count(gram), max_ref_count)

p_n.append(count_clip / len(candidate_grams) if candidate_grams else 0)

# 计算长度惩罚
c = len(candidate)
r = min(len(ref) for ref in references)
bp = 1 if c > r else math.exp(1 - r/c)

# 综合得分
score = sum(w * math.log(p) if p > 0 else 0 for w, p in zip(weights, p_n))
return bp * math.exp(score)

手撕经典算法 #4 经典函数篇
https://hwcoder.top/Manual-Coding-4
作者
Wei He
发布于
2024年7月10日
许可协议