直面配分函数 — 完整知识点与代码实现


一、核心背景:为什么需要直面配分函数?

很多概率模型(如玻尔兹曼机、基于能量的模型)的概率分布形式为:

pmodel(x)=p~model(x)Zp_{\text{model}}(\mathbf{x}) = \frac{\tilde{p}_{\text{model}}(\mathbf{x})}{Z}pmodel(x)=Zp~model(x)

其中 p~\tilde{p}p~ 是未归一化的概率(易计算),Z=∑xp~(x)Z = \sum_{\mathbf{x}} \tilde{p}(\mathbf{x})Z=xp~(x)配分函数(partition function),求和/积分遍历所有可能的状态,在高维空间中几乎不可计算

本章的所有方法都在回答同一个问题:如何在无法计算 ZZZ 的情况下训练和使用这些模型?


二、知识点 1:对数似然梯度

2.1 数学推导

对数似然为:

log⁡pmodel(x)=log⁡p~model(x)−log⁡Z\log p_{\text{model}}(\mathbf{x}) = \log \tilde{p}_{\text{model}}(\mathbf{x}) - \log Zlogpmodel(x)=logp~model(x)logZ

对参数 θ\thetaθ 求梯度:

∇θlog⁡pmodel(x)=∇θlog⁡p~model(x)−∇θlog⁡Z\nabla_\theta \log p_{\text{model}}(\mathbf{x}) = \nabla_\theta \log \tilde{p}_{\text{model}}(\mathbf{x}) - \nabla_\theta \log Zθlogpmodel(x)=θlogp~model(x)θlogZ

关键推导——第二项可以转化为期望:

∇θlog⁡Z=1Z∇θZ=1Z∑x∇θp~(x)=∑xpmodel(x)∇θlog⁡p~(x)\nabla_\theta \log Z = \frac{1}{Z} \nabla_\theta Z = \frac{1}{Z} \sum_{\mathbf{x}} \nabla_\theta \tilde{p}(\mathbf{x}) = \sum_{\mathbf{x}} p_{\text{model}}(\mathbf{x}) \nabla_\theta \log \tilde{p}(\mathbf{x})θlogZ=Z1θZ=Z1xθp~(x)=xpmodel(x)θlogp~(x)

即:

∇θlog⁡Z=Ex∼pmodel[∇θlog⁡p~model(x)]\nabla_\theta \log Z = \mathbb{E}_{\mathbf{x} \sim p_{\text{model}}} \left[ \nabla_\theta \log \tilde{p}_{\text{model}}(\mathbf{x}) \right]θlogZ=Expmodel[θlogp~model(x)]

最终梯度公式(两力平衡):

∇θlog⁡pmodel(x)=∇θlog⁡p~model(x)⏟正相(增大训练数据概率)−Ex∼pmodel[∇θlog⁡p~model(x)]⏟负相(降低模型生成样本概率)\nabla_\theta \log p_{\text{model}}(\mathbf{x}) = \underbrace{\nabla_\theta \log \tilde{p}_{\text{model}}(\mathbf{x})}_{\text{正相(增大训练数据概率)}} - \underbrace{\mathbb{E}_{\mathbf{x} \sim p_{\text{model}}} \left[ \nabla_\theta \log \tilde{p}_{\text{model}}(\mathbf{x}) \right]}_{\text{负相(降低模型生成样本概率)}}θlogpmodel(x)=正相(增大训练数据概率)θlogp~model(x)负相(降低模型生成样本概率)Expmodel[θlogp~model(x)]

  • 正相(Positive Phase):推高训练数据的概率
  • 负相(Negative Phase):压低模型自生成样本的概率
  • 难点在于:负相需要从 pmodelp_{\text{model}}pmodel 中采样,而这本身需要 ZZZ

2.2 案例代码:基于能量模型的对数似然梯度计算

import numpy as np
import matplotlib.pyplot as plt

# ============================================
# 知识点1:对数似然梯度的数值验证
# 以一个简单的基于能量的模型为例
# p_model(x) = exp(-E(x;theta)) / Z(theta)
# E(x; theta) = theta * x^2   (简单的二次能量函数)
# ============================================

def energy(x, theta):
    """
    计算能量函数 E(x; theta) = theta * x^2
    参数:
        x: 数据点,numpy数组
        theta: 模型参数(标量,控制能量曲面的"陡峭"程度)
    返回:
        能量值,形状与 x 相同
    """
    return theta * x ** 2

def unnormalized_log_prob(x, theta):
    """
    计算未归一化的对数概率 log p̃(x) = -E(x; theta)
    在基于能量的模型中,p̃(x) = exp(-E(x; theta))
    因此 log p̃(x) = -E(x; theta) = -theta * x^2
    参数:
        x: 数据点
        theta: 模型参数
    返回:
        log p̃(x) 的值
    """
    return -energy(x, theta)

def unnormalized_prob(x, theta):
    """
    计算未归一化概率 p̃(x) = exp(-E(x; theta))
    用于数值积分求配分函数 Z
    参数:
        x: 数据点
        theta: 模型参数
    返回:
        p̃(x) 的值
    """
    return np.exp(unnormalized_log_prob(x, theta))

def partition_function(theta, x_range=(-10, 10), n_points=10000):
    """
    通过数值积分计算配分函数 Z = ∫ p̃(x) dx
    注意:仅在一维连续空间可行,高维空间此方法不可行
    参数:
        theta: 模型参数
        x_range: 积分范围
        n_points: 积分点数(越多越精确)
    返回:
        配分函数 Z 的近似值
    """
    # 在 x_range 范围内均匀生成采样点
    x_points = np.linspace(x_range[0], x_range[1], n_points)
    # 计算每个点的未归一化概率
    y_points = unnormalized_prob(x_points, theta)
    # 使用梯形法则进行数值积分
    # np.trapz 使用梯形规则近似定积分
    Z = np.trapz(y_points, x_points)
    return Z

def log_partition_function(theta):
    """
    计算 log Z,直接用 log 包裹数值积分结果
    参数:
        theta: 模型参数
    返回:
        log Z 的近似值
    """
    return np.log(partition_function(theta))

def log_likelihood(data, theta):
    """
    计算整个数据集的平均对数似然
    L(theta) = (1/N) * sum_i [log p̃(x_i; theta) - log Z(theta)]
    参数:
        data: 训练数据,一维numpy数组
        theta: 模型参数
    返回:
        平均对数似然值
    """
    # 对每个数据点计算 log p̃(x_i)
    log_p_tilde = unnormalized_log_prob(data, theta)
    # 减去 log Z 得到真正的对数似然
    log_Z = log_partition_function(theta)
    # 返回平均对数似然
    return np.mean(log_p_tilde - log_Z)

def gradient_log_unnormalized_prob(x, theta):
    """
    计算正相梯度:∇_θ log p̃(x; theta)
    由于 log p̃(x) = -theta * x^2
    所以 ∂/∂θ log p̃(x) = -x^2
    参数:
        x: 数据点
        theta: 模型参数(此处不需要,因为梯度不依赖theta)
    返回:
        正相梯度值
    """
    return -x ** 2

def true_log_likelihood_gradient(data, theta):
    """
    计算真实的对数似然梯度(通过数值微分验证)
    使用有限差分法:∂L/∂θ ≈ (L(θ+ε) - L(θ-ε)) / (2ε)
    参数:
        data: 训练数据
        theta: 模型参数
    返回:
        梯度的数值近似
    """
    epsilon = 1e-5  # 微小扰动量
    # 中心差分法,比前向差分更精确
    grad = (log_likelihood(data, theta + epsilon) - 
            log_likelihood(data, theta - epsilon)) / (2 * epsilon)
    return grad

def analytical_log_likelihood_gradient(data, theta, x_range=(-10, 10), n_samples=50000):
    """
    通过解析公式计算对数似然梯度
    ∇_θ L = 正相 - 负相
    正相 = (1/N) * sum_i ∇_θ log p̃(x_i)
    负相 = E_{x~p_model} [∇_θ log p̃(x)]
    
    负相通过从 p_model 采样来近似
    采样方法:使用拒绝采样(rejection sampling)
    参数:
        data: 训练数据
        theta: 模型参数
        x_range: 采样范围
        n_samples: 用于近似负相的采样数
    返回:
        梯度的估计值
    """
    # === 正相计算 ===
    # 对每个训练数据点计算 ∇_θ log p̃(x_i)
    positive_phase = np.mean(gradient_log_unnormalized_prob(data, theta))
    
    # === 负相计算(核心难点)===
    # 需要从 p_model(x) 采样,使用拒绝采样
    # 提议分布使用均匀分布 U(-5, 5)
    # 接受条件:u < p̃(x) / (M * q(x)),其中 M 是上界常数
    
    max_x = 5.0  # 采样范围边界
    # 在网格上找到 p̃(x) 的最大值,作为拒绝采样的上界常数
    x_grid = np.linspace(-max_x, max_x, 10000)
    p_tilde_max = np.max(unnormalized_prob(x_grid, theta))
    
    # 拒绝采样过程
    samples = []  # 存储接受的样本
    attempts = 0  # 记录尝试次数
    while len(samples) < n_samples:
        # 从均匀分布 U(-max_x, max_x) 采样候选点
        x_candidate = np.random.uniform(-max_x, max_x)
        # 计算接受概率:p̃(x) / p̃_max
        # 因为 q(x) 是均匀分布,所以归一化常数被约掉
        accept_prob = unnormalized_prob(x_candidate, theta) / p_tilde_max
        # 以概率 accept_prob 接受该候选点
        if np.random.uniform() < accept_prob:
            samples.append(x_candidate)
        attempts += 1
        # 防止无限循环
        if attempts > n_samples * 100:
            print("警告:拒绝采样效率过低")
            break
    
    samples = np.array(samples)  # 转为numpy数组
    
    # 对采样样本计算梯度的平均值,即负相
    negative_phase = np.mean(gradient_log_unnormalized_prob(samples, theta))
    
    # 最终梯度 = 正相 - 负相
    gradient = positive_phase - negative_phase
    return gradient

# ============ 实验运行 ============

np.random.seed(42)  # 固定随机种子以保证结果可复现

# 生成训练数据:从 N(0, 1) 中采样
# 真实数据分布是一个标准正态分布
train_data = np.random.normal(0, 1, size=500)

# 测试不同 theta 值下的对数似然
theta_values = np.linspace(0.1, 3.0, 50)  # 从0.1到3.0均匀取50个值
ll_values = []  # 存储对应的对数似然值

for theta in theta_values:
    ll = log_likelihood(train_data, theta)
    ll_values.append(ll)

# 打印最优 theta
best_theta = theta_values[np.argmax(ll_values)]
print(f"数值方法找到的最优 theta: {best_theta:.4f}")
print(f"理论最优 theta: 0.5000 (因为真实分布是N(0,1),能量函数theta*x^2对应高斯精度=theta)")

# 验证梯度计算的正确性
test_theta = 1.0
analytical_grad = analytical_log_likelihood_gradient(train_data, test_theta)
numerical_grad = true_log_likelihood_gradient(train_data, test_theta)
print(f"\n在 theta={test_theta} 处:")
print(f"  解析梯度(采样近似): {analytical_grad:.6f}")
print(f"  数值梯度(有限差分): {numerical_grad:.6f}")
print(f"  差异: {abs(analytical_grad - numerical_grad):.6f}")

三、知识点 2:随机最大似然与对比散度

3.1 核心思想

负相需要从 pmodelp_{\text{model}}pmodel 采样,直接采样不可行。两种主要近似方法:

马尔可夫链蒙特卡罗 (MCMC):构造一个以 pmodelp_{\text{model}}pmodel 为平稳分布的马尔可夫链,运行足够步后样本近似服从 pmodelp_{\text{model}}pmodel

吉布斯采样(Gibbs Sampling):对于玻尔兹曼机,逐分量采样:

xi∼pmodel(xi∣x−i)x_i \sim p_{\text{model}}(x_i \mid x_{-i})xipmodel(xixi)

方法思想优点缺点
随机最大似然 (SML)每步用MCMC生成样本,且持久化马尔可夫链的状态(从上一步继续)理论上渐近一致需要足够多的MCMC步数
对比散度 (CD-k)从训练数据初始化MCMC链,只运行 kkk 步(通常 k=1k=1k=1速度快,实践效果好梯度有偏,kkk 越小偏差越大

CD-k 的关键步骤:

  1. 从训练样本 x\mathbf{x}x 出发(初始化MCMC链)
  2. 运行 kkk 步吉布斯采样得到 x′\mathbf{x}'x
  3. 近似梯度:∇θlog⁡p≈∇θlog⁡p~(x)−∇θlog⁡p~(x′)\nabla_\theta \log p \approx \nabla_\theta \log \tilde{p}(\mathbf{x}) - \nabla_\theta \log \tilde{p}(\mathbf{x}')θlogpθlogp~(x)θlogp~(x)

3.2 案例代码:受限玻尔兹曼机(RBM)上的 CD-1 与 SML

import numpy as np

# ============================================
# 知识点2:对比散度(CD-1) 和 随机最大似然(SML) 
# 在受限玻尔兹曼机(RBM)上的实现
# ============================================

class RBM:
    """
    受限玻尔兹曼机 (Restricted Boltzmann Machine)
    结构:可见层 v (n_visible个二值单元) + 隐藏层 h (n_hidden个二值单元)
    能量函数:E(v, h) = -b^T v - c^T h - v^T W h
    联合概率:p(v, h) = exp(-E(v, h)) / Z
    RBM的特殊结构使得:给定一层,另一层的各单元条件独立
    因此可以逐单元进行吉布斯采样
    """
    
    def __init__(self, n_visible, n_hidden, learning_rate=0.01):
        """
        初始化RBM
        参数:
            n_visible: 可见层单元数
            n_hidden: 隐藏层单元数
            learning_rate: 学习率
        """
        self.n_visible = n_visible  # 可见层维度
        self.n_hidden = n_hidden    # 隐藏层维度
        self.lr = learning_rate     # 学习率
        
        # 初始化权重和偏置
        # 权重使用小随机数初始化(打破对称性)
        self.W = np.random.randn(n_visible, n_hidden) * 0.01  # 权重矩阵
        self.b = np.zeros(n_visible)  # 可见层偏置
        self.c = np.zeros(n_hidden)   # 隐藏层偏置
        
        # SML (Persistent Contrastive Divergence) 用的持久化马尔可夫链状态
        self.persistent_chain = None  # 初始为空,训练时初始化
    
    def sigmoid(self, x):
        """
        sigmoid激活函数 σ(x) = 1 / (1 + exp(-x))
        用于计算条件概率 p(h_j=1|v) 和 p(v_i=1|h)
        参数:
            x: 输入值,numpy数组
        返回:
            σ(x) 的值,范围在 (0, 1) 之间
        """
        # np.clip 防止数值溢出(exp过大导致inf)
        x = np.clip(x, -500, 500)
        return 1.0 / (1.0 + np.exp(-x))
    
    def sample_bernoulli(self, probs):
        """
        从伯努利分布中采样
        对于每个元素 p_i,以概率 p_i 生成 1,以概率 1-p_i 生成 0
        参数:
            probs: 概率值,numpy数组,每个元素在 [0,1] 之间
        返回:
            与 probs 同形状的 0/1 二值数组
        """
        # 生成均匀随机数,如果小于对应概率则为1
        return (np.random.random(probs.shape) < probs).astype(np.float64)
    
    def prob_hidden_given_visible(self, v):
        """
        计算隐藏层的条件概率 p(h|v)
        对于RBM:p(h_j=1|v) = σ(c_j + sum_i W_{ij} * v_i) = σ(c + v^T W)
        这是RBM的关键性质:隐藏单元之间条件独立
        参数:
            v: 可见层状态,形状 (batch_size, n_visible)
        返回:
            p(h=1|v),形状 (batch_size, n_hidden)
        """
        # v @ W: 矩阵乘法,形状 (batch, n_visible) × (n_visible, n_hidden) → (batch, n_hidden)
        # + self.c: 广播加上隐藏层偏置
        return self.sigmoid(v @ self.W + self.c)
    
    def prob_visible_given_hidden(self, h):
        """
        计算可见层的条件概率 p(v|h)
        对于RBM:p(v_i=1|h) = σ(b_i + sum_j W_{ij} * h_j) = σ(b + W h)
        参数:
            h: 隐藏层状态,形状 (batch_size, n_hidden)
        返回:
            p(v=1|h),形状 (batch_size, n_visible)
        """
        # h @ W.T: 矩阵乘法,形状 (batch, n_hidden) × (n_hidden, n_visible) → (batch, n_visible)
        return self.sigmoid(h @ self.W.T + self.b)
    
    def gibbs_step(self, v):
        """
        执行一步吉布斯采样:v → h → v'
        1. 给定v采样h:从p(h|v)采样
        2. 给定h采样v':从p(v|h)采样
        这是对比散度和SML的核心操作
        参数:
            v: 当前可见层状态
        返回:
            v_new: 新的可见层状态
            h_prob: 隐藏层概率(用于梯度计算)
            h_sample: 隐藏层采样结果
        """
        # 步骤1:从可见层计算隐藏层概率并采样
        h_prob = self.prob_hidden_given_visible(v)      # 计算 p(h|v)
        h_sample = self.sample_bernoulli(h_prob)         # 从 p(h|v) 采样
        
        # 步骤2:从隐藏层重建可见层概率并采样
        v_prob = self.prob_visible_given_hidden(h_sample)  # 计算 p(v|h)
        v_new = self.sample_bernoulli(v_prob)               # 从 p(v|h) 采样
        
        return v_new, h_prob, h_sample
    
    def contrastive_divergence(self, v_data, k=1):
        """
        对比散度算法 (CD-k)
        
        核心思想:从训练数据初始化马尔可夫链,只运行k步
        近似梯度公式:
        ∂L/∂W ≈ (1/N) * sum_i [v_i * p(h|v_i)^T] - (1/N) * sum_i [v'_i * p(h|v'_i)^T]
        ∂L/∂b ≈ mean(v_data) - mean(v_recon)
        ∂L/∂c ≈ mean(p(h|v_data)) - mean(p(h|v_recon))
        
        参数:
            v_data: 训练数据batch,形状 (batch_size, n_visible)
            k: 吉布斯采样步数(CD-1中k=1)
        返回:
            各参数的梯度
        """
        batch_size = v_data.shape[0]  # 获取batch大小
        
        # === 正相(Positive Phase)===
        # 在数据分布上计算统计量
        h_prob_data = self.prob_hidden_given_visible(v_data)  # p(h|v_data)
        
        # 正相的梯度贡献:数据期望 <v_i * h_j>_data
        # 外积求和:v_data.T @ h_prob_data
        positive_W = v_data.T @ h_prob_data / batch_size  # 形状 (n_visible, n_hidden)
        positive_b = np.mean(v_data, axis=0)               # 可见层偏置的正梯度
        positive_c = np.mean(h_prob_data, axis=0)           # 隐藏层偏置的正梯度
        
        # === 负相(Negative Phase)===
        # 从数据开始运行k步吉布斯采样
        v_current = v_data.copy()  # 复制数据作为MCMC链的起点
        for step in range(k):
            # 每步执行完整的吉布斯采样 v → h → v'
            v_current, h_prob_recon, _ = self.gibbs_step(v_current)
        
        # k步后的隐藏层概率(用于负相梯度计算)
        h_prob_recon = self.prob_hidden_given_visible(v_current)
        
        # 负相的梯度贡献:模型分布期望 <v_i * h_j>_model
        negative_W = v_current.T @ h_prob_recon / batch_size
        negative_b = np.mean(v_current, axis=0)
        negative_c = np.mean(h_prob_recon, axis=0)
        
        # 计算各参数梯度 = 正相 - 负相
        grad_W = positive_W - negative_W
        grad_b = positive_b - negative_b
        grad_c = positive_c - negative_c
        
        return grad_W, grad_b, grad_c
    
    def train_cd(self, data, epochs=100, batch_size=32, k=1):
        """
        使用CD-k训练RBM
        参数:
            data: 训练数据,形状 (n_samples, n_visible)
            epochs: 训练轮数
            batch_size: 批大小
            k: CD的步数
        """
        n_samples = data.shape[0]  # 训练样本总数
        print(f"开始CD-{k}训练,共{epochs}轮...")
        
        for epoch in range(epochs):
            # 打乱数据顺序(随机梯度下降的要求)
            indices = np.random.permutation(n_samples)
            data_shuffled = data[indices]
            
            total_loss = 0  # 累计重建误差
            n_batches = 0   # batch计数
            
            # 按batch遍历数据
            for i in range(0, n_samples, batch_size):
                # 取出当前batch
                batch = data_shuffled[i:i + batch_size]
                
                # 计算CD梯度
                grad_W, grad_b, grad_c = self.contrastive_divergence(batch, k=k)
                
                # 更新参数:θ ← θ + lr * ∇L(最大化对数似然等价于梯度上升)
                self.W += self.lr * grad_W
                self.b += self.lr * grad_b
                self.c += self.lr * grad_c
                
                # 计算重建误差(用于监控训练进度,不是梯度的一部分)
                # 重建步骤:data → hidden → recon
                h_prob = self.prob_hidden_given_visible(batch)
                h_sample = self.sample_bernoulli(h_prob)
                recon = self.prob_visible_given_hidden(h_sample)
                # 使用均方误差衡量重建质量
                loss = np.mean((batch - recon) ** 2)
                total_loss += loss
                n_batches += 1
            
            # 每10轮打印一次训练信息
            if (epoch + 1) % 10 == 0:
                avg_loss = total_loss / n_batches
                print(f"  Epoch {epoch+1}/{epochs}, 平均重建误差: {avg_loss:.4f}")
    
    def train_sml(self, data, epochs=100, batch_size=32, k=1):
        """
        使用随机最大似然(SML) / 持续对比散度(PCD) 训练RBM
        
        SML与CD的核心区别:
        - CD:每个batch都从训练数据重新初始化MCMC链
        - SML:维护一个"持久化"的马尔可夫链,每个batch从上一步结束的位置继续
        
        优势:SML能更好地探索模型分布的全局结构
        参数:
            data: 训练数据
            epochs: 训练轮数
            batch_size: 批大小
            k: 每步的吉布斯采样步数
        """
        n_samples = data.shape[0]
        
        # 初始化持久化链:从训练数据中随机选取一个样本
        # 这个链会在整个训练过程中持续演化
        self.persistent_chain = data[np.random.randint(0, n_samples, batch_size)].copy()
        
        print(f"开始SML训练,共{epochs}轮...")
        
        for epoch in range(epochs):
            indices = np.random.permutation(n_samples)
            data_shuffled = data[indices]
            
            total_loss = 0
            n_batches = 0
            
            for i in range(0, n_samples, batch_size):
                batch = data_shuffled[i:i + batch_size]
                
                # === 正相:在数据上计算 ===
                h_prob_data = self.prob_hidden_given_visible(batch)
                positive_W = batch.T @ h_prob_data / batch.shape[0]
                positive_b = np.mean(batch, axis=0)
                positive_c = np.mean(h_prob_data, axis=0)
                
                # === 负相:在持久化链上计算 ===
                # 关键区别:从persistent_chain继续,而非从数据开始
                v_persistent = self.persistent_chain[:batch.shape[0]].copy()
                
                for step in range(k):
                    # 在持久化链上执行k步吉布斯采样
                    v_persistent, _, _ = self.gibbs_step(v_persistent)
                
                # 更新持久化链状态(供下一个batch使用)
                self.persistent_chain[:batch.shape[0]] = v_persistent.copy()
                
                # 计算负相梯度
                h_prob_persistent = self.prob_hidden_given_visible(v_persistent)
                negative_W = v_persistent.T @ h_prob_persistent / batch.shape[0]
                negative_b = np.mean(v_persistent, axis=0)
                negative_c = np.mean(h_prob_persistent, axis=0)
                
                # 梯度更新
                self.W += self.lr * (positive_W - negative_W)
                self.b += self.lr * (positive_b - negative_b)
                self.c += self.lr * (positive_c - negative_c)
                
                # 重建误差监控
                h_prob = self.prob_hidden_given_visible(batch)
                h_sample = self.sample_bernoulli(h_prob)
                recon = self.prob_visible_given_hidden(h_sample)
                loss = np.mean((batch - recon) ** 2)
                total_loss += loss
                n_batches += 1
            
            if (epoch + 1) % 10 == 0:
                avg_loss = total_loss / n_batches
                print(f"  Epoch {epoch+1}/{epochs}, 平均重建误差: {avg_loss:.4f}")
    
    def reconstruct(self, v):
        """
        用训练好的RBM重建输入数据
        v → p(h|v) → p(v|h) → 重建的v
        参数:
            v: 输入数据
        返回:
            重建后的概率值
        """
        h_prob = self.prob_hidden_given_visible(v)     # 编码
        v_recon = self.prob_visible_given_hidden(h_prob) # 解码
        return v_recon


# ============ 生成模拟数据 ============
# 生成简单的二值模式数据(类似手写数字的简化版)

def generate_patterns(n_samples):
    """
    生成两种二值模式的数据
    模式1:左侧为1,右侧为0(左偏模式)
    模式2:左侧为0,右侧为1(右偏模式)
    加入噪声使其更真实
    参数:
        n_samples: 样本数
    返回:
        二值数据矩阵,形状 (n_samples, 16)
    """
    n_visible = 16  # 4x4 像素的简化图像
    data = np.zeros((n_samples, n_visible))
    
    for i in range(n_samples):
        if np.random.random() < 0.5:
            # 模式1:左侧高激活
            pattern = np.array([1,1,1,1, 1,1,0,0, 0,0,0,0, 0,0,0,0])
        else:
            # 模式2:右侧高激活
            pattern = np.array([0,0,0,0, 0,0,0,0, 0,0,1,1, 1,1,1,1])
        
        # 加入随机翻转噪声(10%概率翻转每个像素)
        noise_mask = np.random.random(n_visible) < 0.1
        pattern = pattern.copy()
        pattern[noise_mask] = 1 - pattern[noise_mask]  # 翻转被噪声选中的位
        
        data[i] = pattern
    
    return data

# 生成训练数据
np.random.seed(42)
train_data = generate_patterns(500)

# ============ 训练与对比 ============

# 用CD-1训练
print("=" * 50)
print("方法1: CD-1 训练")
print("=" * 50)
rbm_cd = RBM(n_visible=16, n_hidden=8, learning_rate=0.05)
rbm_cd.train_cd(train_data, epochs=50, batch_size=32, k=1)

# 用SML训练
print("\n" + "=" * 50)
print("方法2: SML 训练")
print("=" * 50)
rbm_sml = RBM(n_visible=16, n_hidden=8, learning_rate=0.05)
rbm_sml.train_sml(train_data, epochs=50, batch_size=32, k=1)

# 对比重建效果
test_sample = train_data[0:1]  # 取第一个样本做测试
print(f"\n原始数据:    {test_sample[0].astype(int)}")
print(f"CD-1重建:    {(rbm_cd.reconstruct(test_sample)[0] > 0.5).astype(int)}")
print(f"SML重建:     {(rbm_sml.reconstruct(test_sample)[0] > 0.5).astype(int)}")

四、知识点 3:伪似然(Pseudolikelihood)

3.1 核心思想

直接计算 p(x)p(\mathbf{x})p(x) 需要 ZZZ,但条件概率 p(xi∣x−i)p(x_i \mid \mathbf{x}_{-i})p(xixi) 中配分函数会被约掉:

p(xi∣x−i)=p(x)∑xi′p(xi′,x−i)=p~(x)∑xi′p~(xi′,x−i)p(x_i \mid \mathbf{x}_{-i}) = \frac{p(\mathbf{x})}{\sum_{x_i'} p(x_i', \mathbf{x}_{-i})} = \frac{\tilde{p}(\mathbf{x})}{\sum_{x_i'} \tilde{p}(x_i', \mathbf{x}_{-i})}p(xixi)=xip(xi,xi)p(x)=xip~(xi,xi)p~(x)

分母只需对 xix_ixi 的可能取值求和(如二值情况下只有两项),不需要全局配分函数

伪似然目标函数:

PL(θ)=∑i=1nlog⁡pθ(xi∣x−i)\text{PL}(\theta) = \sum_{i=1}^{n} \log p_\theta(x_i \mid \mathbf{x}_{-i})PL(θ)=i=1nlogpθ(xixi)

  • 优点:完全避免配分函数的计算,梯度无偏
  • 缺点:假设各维度条件独立(不适用于需要捕获长程依赖的场景),训练和评估不一致(优化的是伪似然而非真正的似然)

3.2 案例代码

import numpy as np

# ============================================
# 知识点3:伪似然 (Pseudolikelihood)
# 在基于能量的模型中实现伪似然训练
# ============================================

class EnergyBasedModel:
    """
    基于能量的模型(Ising模型的一种推广)
    能量函数:E(x; W, b) = -x^T W x - b^T x
    其中 W 是对称权重矩阵(无自连接),b 是偏置
    未归一化概率:p̃(x) = exp(-E(x; W, b))
    """
    
    def __init__(self, n_units, learning_rate=0.01):
        """
        初始化基于能量的模型
        参数:
            n_units: 变量维度(单元数)
            learning_rate: 学习率
        """
        self.n = n_units
        self.lr = learning_rate
        # 初始化权重为小随机数,对称矩阵
        # 乘以0.01防止初始能量差异过大
        self.W = np.random.randn(n_units, n_units) * 0.01
        # 使权重矩阵对称:W = (W + W^T) / 2
        self.W = (self.W + self.W.T) / 2
        # 对角线置零(无自连接)
        np.fill_diagonal(self.W, 0)
        # 偏置初始化为零
        self.b = np.zeros(n_units)
    
    def energy(self, x):
        """
        计算能量函数 E(x) = -x^T W x - b^T x
        参数:
            x: 状态向量,形状 (batch_size, n_units)
        返回:
            每个样本的能量值,形状 (batch_size,)
        """
        # x @ self.W @ x.T 的对角线元素等于每个样本的 x^T W x
        # 更高效的方式:逐元素乘法再求和
        interaction = -0.5 * np.sum((x @ self.W) * x, axis=1)  # 交互项
        bias = -x @ self.b  # 偏置项
        return interaction + bias
    
    def unnormalized_log_prob(self, x):
        """
        计算 log p̃(x) = -E(x)
        参数:
            x: 状态向量
        返回:
            log p̃(x) 的值
        """
        return -self.energy(x)
    
    def conditional_prob(self, x, i):
        """
        计算条件概率 p(x_i = 1 | x_{-i})
        
        关键推导:
        p(x_i=1|x_{-i}) / p(x_i=0|x_{-i}) = exp(-E(x_i=1,x_{-i}) + E(x_i=0,x_{-i}))
        
        令 ΔE = E(x_i=1, x_{-i}) - E(x_i=0, x_{-i})
        则 p(x_i=1|x_{-i}) = σ(-ΔE) = 1/(1+exp(ΔE))
        
        对于Ising模型:
        ΔE = -(sum_j W_{ij} * x_j + b_i)
        所以 p(x_i=1|x_{-i}) = σ(sum_j W_{ij} * x_j + b_i)
        
        参数:
            x: 当前状态,形状 (batch_size, n_units)
            i: 要计算条件概率的变量索引
        返回:
            p(x_i=1|x_{-i}),形状 (batch_size,)
        """
        # 计算 x_i 的局部场(所有邻居对i的影响之和)
        # h_i = sum_j W_{ij} * x_j + b_i
        # 注意:W_{ii}=0,所以不需要特殊处理
        local_field = x @ self.W[:, i] + self.b[i]
        # 条件概率 = sigmoid(local_field)
        return 1.0 / (1.0 + np.exp(-np.clip(local_field, -500, 500)))
    
    def pseudolikelihood(self, x):
        """
        计算伪似然:PL(x) = sum_i log p(x_i | x_{-i})
        
        对于二值变量,x_i ∈ {0, 1}:
        log p(x_i | x_{-i}) = x_i * log(p_i) + (1-x_i) * log(1-p_i)
        其中 p_i = p(x_i=1 | x_{-i})
        
        这就是对每个维度分别计算二元交叉熵
        参数:
            x: 数据,形状 (batch_size, n_units)
        返回:
            每个样本的伪似然值,形状 (batch_size,)
        """
        batch_size = x.shape[0]
        pl = np.zeros(batch_size)  # 初始化伪似然累加器
        
        for i in range(self.n):
            # 计算第i个变量的条件概率
            p_i = self.conditional_prob(x, i)
            # 二元交叉熵:x_i * log(p_i) + (1-x_i) * log(1-p_i)
            # 添加小常数1e-10防止log(0)
            log_p = x[:, i] * np.log(p_i + 1e-10) + \
                    (1 - x[:, i]) * np.log(1 - p_i + 1e-10)
            pl += log_p  # 累加到伪似然
        
        return pl
    
    def pseudolikelihood_gradient(self, x):
        """
        计算伪似然关于参数 W 和 b 的梯度
        
        ∂PL/∂W_{ij} = sum_k [∂/∂W_{ij} log p(x_k | x_{-k})]
        
        对于 k = i 或 k = j,梯度不为零(因为 W_{ij} 只影响 x_i 和 x_j 的条件概率)
        
        更简洁的实现:对每个维度 i,
        ∂PL/∂W_{ij} = (x_i - p_i) * x_j  (对于所有 j ≠ i)
        ∂PL/∂b_i = x_i - p_i
        
        参数:
            x: 数据,形状 (batch_size, n_units)
        返回:
            grad_W: W的梯度
            grad_b: b的梯度
        """
        batch_size = x.shape[0]
        grad_W = np.zeros_like(self.W)  # 权重梯度,形状 (n, n)
        grad_b = np.zeros_like(self.b)  # 偏置梯度,形状 (n,)
        
        for i in range(self.n):
            # 计算 p_i = p(x_i=1|x_{-i})
            p_i = self.conditional_prob(x, i)  # 形状 (batch_size,)
            
            # "残差":实际值 - 预测概率
            residual = x[:, i] - p_i  # 形状 (batch_size,)
            
            # 对偏置的梯度:∂PL/∂b_i = (1/N) * sum(residual)
            grad_b[i] += np.mean(residual)
            
            # 对权重的梯度:∂PL/∂W_{ij} = (1/N) * sum(residual * x_j)
            # 这是一个向量外积的期望
            # residual[:, None] 将其变为列向量,x 是 (batch, n)
            grad_W[i, :] += np.mean(residual[:, None] * x, axis=0)
        
        # 使梯度矩阵对称(因为W是对称的)
        grad_W = (grad_W + grad_W.T) / 2
        # 对角线置零(与W的约束一致)
        np.fill_diagonal(grad_W, 0)
        
        return grad_W, grad_b
    
    def train_pseudolikelihood(self, data, epochs=100, batch_size=32):
        """
        使用伪似然训练基于能量的模型
        参数:
            data: 训练数据,形状 (n_samples, n_units)
            epochs: 训练轮数
            batch_size: 批大小
        """
        n_samples = data.shape[0]
        print("开始伪似然训练...")
        
        for epoch in range(epochs):
            # 打乱数据
            indices = np.random.permutation(n_samples)
            data_shuffled = data[indices]
            
            total_pl = 0  # 累计伪似然
            n_batches = 0
            
            for i in range(0, n_samples, batch_size):
                batch = data_shuffled[i:i + batch_size]
                
                # 计算伪似然梯度
                grad_W, grad_b = self.pseudolikelihood_gradient(batch)
                
                # 梯度上升更新(最大化伪似然)
                self.W += self.lr * grad_W
                self.b += self.lr * grad_b
                
                # 记录伪似然值(用于监控)
                pl = np.mean(self.pseudolikelihood(batch))
                total_pl += pl
                n_batches += 1
            
            if (epoch + 1) % 10 == 0:
                avg_pl = total_pl / n_batches
                print(f"  Epoch {epoch+1}/{epochs}, 平均伪似然: {avg_pl:.4f}")


# ============ 实验 ============
np.random.seed(42)

# 生成具有空间相关性的二值数据
def generate_correlated_data(n_samples, n_units):
    """
    生成具有相邻单元相关性的二值数据
    使用链式依赖结构:x_i 与 x_{i-1} 正相关
    参数:
        n_samples: 样本数
        n_units: 变量维度
    返回:
        二值数据矩阵
    """
    data = np.zeros((n_samples, n_units))
    # 第一个单元随机
    data[:, 0] = (np.random.random(n_samples) > 0.5).astype(float)
    
    for i in range(1, n_units):
        # 后续单元以80%概率与前一个单元相同,20%概率相反
        # 这创建了相邻单元之间的正相关
        same_as_prev = np.random.random(n_samples) < 0.8
        data[:, i] = np.where(same_as_prev, data[:, i-1], 1 - data[:, i-1])
    
    return data

# 生成训练数据
train_data = generate_correlated_data(1000, n_units=10)

# 训练模型
model = EnergyBasedModel(n_units=10, learning_rate=0.05)
model.train_pseudolikelihood(train_data, epochs=50, batch_size=64)

# 查看学到的权重矩阵
print("\n学到的权重矩阵W(应显示相邻单元间的正权重):")
print(np.round(model.W[:5, :5], 3))  # 打印前5x5的子矩阵

五、知识点 4:得分匹配与比率匹配

5.1 得分匹配(Score Matching)

得分(Score) 定义为对数概率密度的梯度:

s(x)=∇xlog⁡pmodel(x)s(\mathbf{x}) = \nabla_\mathbf{x} \log p_{\text{model}}(\mathbf{x})s(x)=xlogpmodel(x)

关键观察:得分的计算不依赖配分函数,因为 ∇xlog⁡Z=0\nabla_\mathbf{x} \log Z = 0xlogZ=0

s(x)=∇xlog⁡p~(x)s(\mathbf{x}) = \nabla_\mathbf{x} \log \tilde{p}(\mathbf{x})s(x)=xlogp~(x)

得分匹配目标:令模型得分匹配数据得分。目标函数:

J(θ)=12Epdata[∥smodel(x;θ)−sdata(x)∥2]J(\theta) = \frac{1}{2} \mathbb{E}_{p_{\text{data}}} \left[ \| s_{\text{model}}(\mathbf{x}; \theta) - s_{\text{data}}(\mathbf{x}) \|^2 \right]J(θ)=21Epdata[smodel(x;θ)sdata(x)2]

通过分部积分,可以避免直接计算 sdatas_{\text{data}}sdata

J(θ)=Epdata[tr(∇xsmodel(x))+12∥smodel(x)∥2]J(\theta) = \mathbb{E}_{p_{\text{data}}} \left[ \text{tr}(\nabla_\mathbf{x} s_{\text{model}}(\mathbf{x})) + \frac{1}{2} \| s_{\text{model}}(\mathbf{x}) \|^2 \right]J(θ)=Epdata[tr(xsmodel(x))+21smodel(x)2]

5.2 比率匹配(Ratio Matching)

适用于离散数据。目标是匹配概率比率 p(x)p(¬ix)\frac{p(\mathbf{x})}{p(\neg_i \mathbf{x})}p(¬ix)p(x),其中 ¬ix\neg_i \mathbf{x}¬ix 是翻转第 iii 位后的向量。

J(θ)=∑i=1nEpdata[(11+exp⁡(si(x)−si(¬ix)))2]J(\theta) = \sum_{i=1}^{n} \mathbb{E}_{p_{\text{data}}} \left[ \left( \frac{1}{1 + \exp(s_i(\mathbf{x}) - s_i(\neg_i \mathbf{x}))} \right)^2 \right]J(θ)=i=1nEpdata[(1+exp(si(x)si(¬ix))1)2]

其中 si(x)=log⁡p~(¬ix)−log⁡p~(x)s_i(\mathbf{x}) = \log \tilde{p}(\neg_i \mathbf{x}) - \log \tilde{p}(\mathbf{x})si(x)=logp~(¬ix)logp~(x)

5.3 案例代码

import numpy as np

# ============================================
# 知识点4:得分匹配 (Score Matching) 与 比率匹配 (Ratio Matching)
# ============================================

class GaussianEBM:
    """
    基于能量的连续模型(高斯形式)
    p̃(x) = exp(-0.5 * x^T A x - b^T x)
    其中 A 是正定矩阵,b 是偏置向量
    这对应一个多元高斯分布
    """
    
    def __init__(self, n_dim, learning_rate=0.001):
        """
        初始化模型
        参数:
            n_dim: 数据维度
            learning_rate: 学习率
        """
        self.n_dim = n_dim
        self.lr = learning_rate
        # A: 正定矩阵参数(初始化为单位阵)
        self.A = np.eye(n_dim)
        # b: 偏置向量
        self.b = np.zeros(n_dim)
    
    def score(self, x):
        """
        计算模型得分 s(x) = ∇_x log p̃(x)
        
        推导:
        log p̃(x) = -0.5 * x^T A x - b^T x
        ∇_x log p̃(x) = -A x - b
        
        参数:
            x: 数据点,形状 (batch_size, n_dim)
        返回:
            得分向量,形状 (batch_size, n_dim)
        """
        # -(x @ A^T + b),由于A是对称的,A^T = A
        return -(x @ self.A + self.b)
    
    def score_jacobian(self, x):
        """
        计算得分的雅可比矩阵 ∇_x s(x) = ∂s_i/∂x_j
        
        对于 s(x) = -Ax - b
        雅可比矩阵 ∂s/∂x = -A(与x无关)
        
        但在一般情况下雅可比矩阵依赖x
        这里返回 -A 的 batch 广播版本
        
        参数:
            x: 数据点,形状 (batch_size, n_dim)
        返回:
            雅可比张量,形状 (batch_size, n_dim, n_dim)
        """
        batch_size = x.shape[0]
        # 对于线性能量模型,雅可比是常数 -A
        return np.tile(-self.A, (batch_size, 1, 1))
    
    def score_matching_loss(self, x):
        """
        计算得分匹配的损失函数
        
        J(θ) = E[tr(∇_x s(x)) + 0.5 * ||s(x)||^2]
        
        这是经过分部积分后的形式,不需要知道数据得分
        参数:
            x: 数据batch,形状 (batch_size, n_dim)
        返回:
            损失值(标量)
        """
        batch_size = x.shape[0]
        
        # 计算模型得分
        s = self.score(x)  # 形状 (batch_size, n_dim)
        
        # 计算得分雅可比矩阵
        jac = self.score_jacobian(x)  # 形状 (batch_size, n_dim, n_dim)
        
        # 项1: tr(∇_x s(x)) = 对每个样本计算雅可比矩阵的迹
        # np.trace 沿最后两个轴计算迹
        trace_term = np.trace(jac, axis1=1, axis2=2)  # 形状 (batch_size,)
        
        # 项2: 0.5 * ||s(x)||^2
        norm_term = 0.5 * np.sum(s ** 2, axis=1)  # 形状 (batch_size,)
        
        # 总损失 = 两项之和的均值
        loss = np.mean(trace_term + norm_term)
        return loss
    
    def score_matching_gradient(self, x):
        """
        计算得分匹配损失对参数A和b的梯度
        
        对于线性能量模型有解析梯度:
        ∂J/∂A = -I + E[(Ax+b)(Ax+b)^T] + E[A x x^T](简化形式)
        
        这里使用自动微分思想手动推导
        参数:
            x: 数据batch
        返回:
            grad_A, grad_b
        """
        batch_size = x.shape[0]
        
        # 得分 s(x) = -Ax - b
        s = self.score(x)  # (batch_size, n_dim)
        
        # 雅可比的迹 = -tr(A) = -sum(A的对角元素)
        trace_A = np.trace(self.A)
        
        # ∂J/∂A:
        # 项1: ∂tr(∇_x s)/∂A = -I(因为tr(-A)对A的梯度是-I)
        grad_A_trace = -np.eye(self.n_dim)
        # 项2: ∂(0.5||s||^2)/∂A = E[s * (-x^T)] 因为 s = -Ax-b
        # = -E[s @ x^T] 但还需要考虑对称性
        grad_A_norm = s.T @ x / batch_size  # (n_dim, n_dim)
        grad_A = grad_A_trace + grad_A_norm
        
        # ∂J/∂b:
        # 项1: tr(∇_x s)/∂b = 0(迹不依赖b)
        # 项2: ∂(0.5||s||^2)/∂b = E[s * (-1)] = -E[s]
        grad_b = -np.mean(s, axis=0)
        
        return grad_A, grad_b
    
    def train_score_matching(self, data, epochs=200, batch_size=64):
        """
        使用得分匹配训练模型
        参数:
            data: 训练数据
            epochs: 训练轮数
            batch_size: 批大小
        """
        n_samples = data.shape[0]
        print("开始得分匹配训练...")
        
        for epoch in range(epochs):
            indices = np.random.permutation(n_samples)
            data_shuffled = data[indices]
            
            total_loss = 0
            n_batches = 0
            
            for i in range(0, n_samples, batch_size):
                batch = data_shuffled[i:i + batch_size]
                
                # 计算梯度
                grad_A, grad_b = self.score_matching_gradient(batch)
                
                # 梯度下降(最小化损失)
                self.A -= self.lr * grad_A
                self.b -= self.lr * grad_b
                
                # 确保A保持对称
                self.A = (self.A + self.A.T) / 2
                
                loss = self.score_matching_loss(batch)
                total_loss += loss
                n_batches += 1
            
            if (epoch + 1) % 50 == 0:
                print(f"  Epoch {epoch+1}/{epochs}, 损失: {total_loss/n_batches:.4f}")


class DiscreteRatioMatching:
    """
    比率匹配模型,用于离散二值数据
    
    比率匹配的核心思想:
    匹配 log p̃(¬_i x) / p̃(x) 的比率,其中 ¬_i x 是翻转第i位的向量
    """
    
    def __init__(self, n_dim, learning_rate=0.01):
        """
        初始化比率匹配模型
        参数:
            n_dim: 数据维度
            learning_rate: 学习率
        """
        self.n_dim = n_dim
        self.lr = learning_rate
        # 使用简单的线性模型: log p̃(x) = b^T x + x^T W x
        self.W = np.random.randn(n_dim, n_dim) * 0.01
        self.W = (self.W + self.W.T) / 2  # 对称
        np.fill_diagonal(self.W, 0)
        self.b = np.zeros(n_dim)
    
    def log_unnormalized_prob(self, x):
        """
        计算 log p̃(x) = x^T W x + b^T x
        参数:
            x: 二值数据,形状 (batch_size, n_dim)
        返回:
            log p̃(x) 的值,形状 (batch_size,)
        """
        # 交互项 + 偏置项
        return np.sum((x @ self.W) * x, axis=1) + x @ self.b
    
    def ratio_matching_loss(self, x):
        """
        计算比率匹配损失
        
        对于每个样本和每个维度 i:
        1. 构造 ¬_i x(翻转第i位)
        2. 计算 s_i = log p̃(¬_i x) - log p̃(x)
        3. 损失项 = (1/(1+exp(s_i)))^2
        
        直觉:如果模型正确,则翻转一位后的概率应与原概率有一定比率关系
        参数:
            x: 数据batch,形状 (batch_size, n_dim)
        返回:
            损失值
        """
        batch_size = x.shape[0]
        log_p_x = self.log_unnormalized_prob(x)  # log p̃(x)
        
        total_loss = 0  # 累计损失
        
        for i in range(self.n_dim):
            # 构造翻转第i位的样本 ¬_i x
            x_flipped = x.copy()           # 复制原数据
            x_flipped[:, i] = 1 - x[:, i]  # 翻转第i位(0→1, 1→0)
            
            # 计算 log p̃(¬_i x)
            log_p_flipped = self.log_unnormalized_prob(x_flipped)
            
            # 计算 s_i = log p̃(¬_i x) - log p̃(x)
            s_i = log_p_flipped - log_p_x
            
            # σ(-s_i)^2 = (1/(1+exp(s_i)))^2
            # 使用 sigmoid 函数
            sigma_neg = 1.0 / (1.0 + np.exp(np.clip(s_i, -500, 500)))
            loss_i = sigma_neg ** 2
            
            total_loss += np.mean(loss_i)
        
        return total_loss / self.n_dim  # 对所有维度取平均
    
    def ratio_matching_gradient(self, x):
        """
        计算比率匹配损失的梯度(数值近似版,更稳健)
        使用有限差分法
        参数:
            x: 数据batch
        返回:
            grad_W, grad_b
        """
        epsilon = 1e-5
        grad_W = np.zeros_like(self.W)
        grad_b = np.zeros_like(self.b)
        
        # 当前损失
        loss_orig = self.ratio_matching_loss(x)
        
        # 对b的梯度
        for i in range(self.n_dim):
            self.b[i] += epsilon
            loss_plus = self.ratio_matching_loss(x)
            self.b[i] -= epsilon
            grad_b[i] = (loss_plus - loss_orig) / epsilon
        
        # 对W的梯度(只计算上三角,然后对称化)
        for i in range(self.n_dim):
            for j in range(i + 1, self.n_dim):
                self.W[i, j] += epsilon
                self.W[j, i] += epsilon
                loss_plus = self.ratio_matching_loss(x)
                self.W[i, j] -= epsilon
                self.W[j, i] -= epsilon
                grad_W[i, j] = (loss_plus - loss_orig) / epsilon
                grad_W[j, i] = grad_W[i, j]  # 对称
        
        return grad_W, grad_b
    
    def train(self, data, epochs=50, batch_size=32):
        """
        使用比率匹配训练模型
        参数:
            data: 训练数据
            epochs: 训练轮数
            batch_size: 批大小
        """
        n_samples = data.shape[0]
        print("开始比率匹配训练...")
        
        for epoch in range(epochs):
            indices = np.random.permutation(n_samples)
            data_shuffled = data[indices]
            
            total_loss = 0
            n_batches = 0
            
            for i in range(0, n_samples, batch_size):
                batch = data_shuffled[i:i + batch_size]
                
                grad_W, grad_b = self.ratio_matching_gradient(batch)
                
                # 梯度下降
                self.W -= self.lr * grad_W
                self.b -= self.lr * grad_b
                self.W = (self.W + self.W.T) / 2
                np.fill_diagonal(self.W, 0)
                
                loss = self.ratio_matching_loss(batch)
                total_loss += loss
                n_batches += 1
            
            if (epoch + 1) % 10 == 0:
                print(f"  Epoch {epoch+1}/{epochs}, 损失: {total_loss/n_batches:.4f}")


# ============ 得分匹配实验 ============
np.random.seed(42)

# 生成二维高斯数据(真实分布)
# 真实协均值矩阵 Sigma = [[2, 0.5], [0.5, 1]]
true_mean = np.array([1.0, -0.5])
true_cov = np.array([[2.0, 0.5], [0.5, 1.0]])
train_data = np.random.multivariate_normal(true_mean, true_cov, size=1000)

# 训练得分匹配模型
model_sm = GaussianEBM(n_dim=2, learning_rate=0.001)
model_sm.train_score_matching(train_data, epochs=200, batch_size=64)

# 比较学到的参数与真实参数
# 真实精度矩阵 A_true = Sigma^{-1}
true_precision = np.linalg.inv(true_cov)
print(f"\n真实精度矩阵 A:\n{np.round(true_precision, 4)}")
print(f"学到的矩阵 A:\n{np.round(model_sm.A, 4)}")

# ============ 比率匹配实验 ============
# 生成简单二值数据
binary_data = np.zeros((500, 6))
for i in range(500):
    if np.random.random() < 0.5:
        binary_data[i] = [1,1,0,0,0,0]
    else:
        binary_data[i] = [0,0,1,1,0,0]

model_rm = DiscreteRatioMatching(n_dim=6, learning_rate=0.01)
model_rm.train(binary_data, epochs=30, batch_size=32)
print(f"\n学到的偏置 b:\n{np.round(model_rm.b, 3)}")

六、知识点 5:去噪得分匹配(Denoising Score Matching)

6.1 核心思想

得分匹配需要计算 ∇xs(x)\nabla_\mathbf{x} s(\mathbf{x})xs(x)(雅可比矩阵),这在高维空间计算代价很高。

去噪得分匹配通过向数据添加噪声来简化问题:

  1. 对数据 x\mathbf{x}x 添加噪声:x~=x+ϵ,ϵ∼N(0,σ2I)\tilde{\mathbf{x}} = \mathbf{x} + \epsilon, \quad \epsilon \sim \mathcal{N}(0, \sigma^2 I)x~=x+ϵ,ϵN(0,σ2I)
  2. 目标变为匹配去噪后的得分

J(θ)=12EpdataEqσ(x~∣x)[∥sθ(x~)−∇x~log⁡qσ(x~∣x)∥2]J(\theta) = \frac{1}{2} \mathbb{E}_{p_{\text{data}}} \mathbb{E}_{q_\sigma(\tilde{\mathbf{x}} | \mathbf{x})} \left[ \| s_\theta(\tilde{\mathbf{x}}) - \nabla_{\tilde{\mathbf{x}}} \log q_\sigma(\tilde{\mathbf{x}} | \mathbf{x}) \|^2 \right]J(θ)=21EpdataEqσ(x~x)[sθ(x~)x~logqσ(x~x)2]

其中 ∇x~log⁡qσ(x~∣x)=−x~−xσ2\nabla_{\tilde{\mathbf{x}}} \log q_\sigma(\tilde{\mathbf{x}} | \mathbf{x}) = -\frac{\tilde{\mathbf{x}} - \mathbf{x}}{\sigma^2}x~logqσ(x~x)=σ2x~x

优势

  • 不需要计算得分的雅可比矩阵
  • 概率密度 q(x~∣x)q(\tilde{x}|x)q(x~x) 已知且简单(高斯噪声)
  • 在高维空间中更实用

6.2 案例代码

import numpy as np

# ============================================
# 知识点5:去噪得分匹配 (Denoising Score Matching)
# ============================================

class DenoisingScoreMatching:
    """
    去噪得分匹配模型
    使用神经网络参数化得分函数 s_θ(x) ≈ ∇_x log p_data(x)
    
    核心流程:
    1. 从训练数据采样 x
    2. 给 x 加噪声得到 x̃ = x + σ*ε
    3. 目标得分 = -(x̃ - x)/σ²  (已知的高斯噪声得分)
    4. 训练网络预测这个目标得分
    """
    
    def __init__(self, n_dim, hidden_dim=64, noise_std=0.1, learning_rate=0.001):
        """
        初始化去噪得分匹配模型
        参数:
            n_dim: 数据维度
            hidden_dim: 隐藏层维度
            noise_std: 噪声标准差 σ(核心超参数)
            learning_rate: 学习率
        """
        self.n_dim = n_dim
        self.hidden_dim = hidden_dim
        self.sigma = noise_std  # 噪声标准差
        self.lr = learning_rate
        
        # 简单的两层神经网络参数
        # 第一层:输入层 → 隐藏层
        self.W1 = np.random.randn(n_dim, hidden_dim) * np.sqrt(2.0 / n_dim)
        self.b1 = np.zeros(hidden_dim)
        # 第二层:隐藏层 → 输出层(输出得分向量)
        self.W2 = np.random.randn(hidden_dim, n_dim) * np.sqrt(2.0 / hidden_dim)
        self.b2 = np.zeros(n_dim)
    
    def relu(self, x):
        """
        ReLU激活函数: f(x) = max(0, x)
        参数:
            x: 输入
        返回:
            max(0, x)
        """
        return np.maximum(0, x)
    
    def relu_derivative(self, x):
        """
        ReLU的导数: f'(x) = 1 if x > 0, else 0
        参数:
            x: 输入
        返回:
            导数值
        """
        return (x > 0).astype(float)
    
    def forward(self, x):
        """
        前向传播,计算得分预测 s_θ(x)
        架构: x → [W1, b1] → ReLU → [W2, b2] → s_θ(x)
        参数:
            x: 输入数据,形状 (batch_size, n_dim)
        返回:
            score_pred: 预测的得分,形状 (batch_size, n_dim)
            cache: 中间结果(用于反向传播)
        """
        # 第一层的线性变换
        z1 = x @ self.W1 + self.b1      # 形状 (batch, hidden_dim)
        # ReLU激活
        a1 = self.relu(z1)               # 形状 (batch, hidden_dim)
        # 第二层的线性变换(输出层无激活函数)
        score_pred = a1 @ self.W2 + self.b2  # 形状 (batch, n_dim)
        
        # 缓存中间结果用于反向传播
        cache = {'x': x, 'z1': z1, 'a1': a1}
        return score_pred, cache
    
    def add_noise(self, x):
        """
        给数据添加高斯噪声
        x̃ = x + σ * ε,  ε ~ N(0, I)
        参数:
            x: 原始数据,形状 (batch_size, n_dim)
        返回:
            x_noisy: 加噪后数据
            noise: 添加的噪声(用于计算目标得分)
        """
        noise = np.random.randn(*x.shape)  # 生成标准正态噪声
        x_noisy = x + self.sigma * noise   # 加噪
        return x_noisy, noise
    
    def target_score(self, x, x_noisy):
        """
        计算目标得分(噪声条件概率的梯度)
        
        对于 q(x̃|x) = N(x̃; x, σ²I)
        ∇_{x̃} log q(x̃|x) = -(x̃ - x)/σ²
        
        这是已知的解析表达式,不需要估计
        参数:
            x: 原始数据
            x_noisy: 加噪后数据
        返回:
            目标得分向量
        """
        return -(x_noisy - x) / (self.sigma ** 2)
    
    def dsm_loss(self, x):
        """
        计算去噪得分匹配损失
        
        L(θ) = (1/2) * E[||s_θ(x̃) - ∇log q(x̃|x)||²]
        
        直觉:让网络在噪声输入上预测"指向真实数据的方向"
        参数:
            x: 原始数据batch
        返回:
            loss: 损失值
            cache: 中间结果
        """
        # 步骤1: 添加噪声
        x_noisy, noise = self.add_noise(x)
        
        # 步骤2: 神经网络预测得分
        score_pred, cache = self.forward(x_noisy)
        
        # 步骤3: 计算目标得分
        score_target = self.target_score(x, x_noisy)
        
        # 步骤4: MSE损失
        diff = score_pred - score_target  # 形状 (batch, n_dim)
        loss = 0.5 * np.mean(np.sum(diff ** 2, axis=1))  # 对维度求和,对batch求平均
        
        cache['x_noisy'] = x_noisy
        cache['score_pred'] = score_pred
        cache['score_target'] = score_target
        cache['diff'] = diff
        
        return loss, cache
    
    def backward(self, cache):
        """
        反向传播计算梯度
        参数:
            cache: 前向传播和损失计算的中间结果
        返回:
            各参数的梯度
        """
        batch_size = cache['x'].shape[0]
        diff = cache['diff']  # score_pred - score_target,形状 (batch, n_dim)
        
        # ∂L/∂score_pred = diff(MSE的梯度)
        d_score = diff / batch_size  # 形状 (batch, n_dim)
        
        # 第二层梯度
        # score = a1 @ W2 + b2
        # ∂L/∂W2 = a1^T @ d_score
        grad_W2 = cache['a1'].T @ d_score        # 形状 (hidden_dim, n_dim)
        grad_b2 = np.sum(d_score, axis=0)          # 形状 (n_dim,)
        
        # 传播到隐藏层
        # ∂L/∂a1 = d_score @ W2^T
        d_a1 = d_score @ self.W2.T                 # 形状 (batch, hidden_dim)
        
        # ReLU的梯度
        d_z1 = d_a1 * self.relu_derivative(cache['z1'])  # 形状 (batch, hidden_dim)
        
        # 第一层梯度
        # ∂L/∂W1 = x^T @ d_z1
        # 注意:这里的x实际是x_noisy(网络的输入是加噪数据)
        grad_W1 = cache['x_noisy'].T @ d_z1      # 形状 (n_dim, hidden_dim)
        grad_b1 = np.sum(d_z1, axis=0)              # 形状 (hidden_dim,)
        
        return grad_W1, grad_b1, grad_W2, grad_b2
    
    def train(self, data, epochs=200, batch_size=64):
        """
        训练去噪得分匹配模型
        参数:
            data: 训练数据
            epochs: 训练轮数
            batch_size: 批大小
        """
        n_samples = data.shape[0]
        print(f"开始去噪得分匹配训练 (σ={self.sigma})...")
        
        for epoch in range(epochs):
            indices = np.random.permutation(n_samples)
            data_shuffled = data[indices]
            
            total_loss = 0
            n_batches = 0
            
            for i in range(0, n_samples, batch_size):
                batch = data_shuffled[i:i + batch_size]
                
                # 前向传播 + 损失计算
                loss, cache = self.dsm_loss(batch)
                
                # 反向传播
                grad_W1, grad_b1, grad_W2, grad_b2 = self.backward(cache)
                
                # 梯度下降更新
                self.W1 -= self.lr * grad_W1
                self.b1 -= self.lr * grad_b1
                self.W2 -= self.lr * grad_W2
                self.b2 -= self.lr * grad_b2
                
                total_loss += loss
                n_batches += 1
            
            if (epoch + 1) % 50 == 0:
                print(f"  Epoch {epoch+1}/{epochs}, 损失: {total_loss/n_batches:.4f}")
    
    def predict_score(self, x):
        """
        用训练好的模型预测得分
        参数:
            x: 数据点
        返回:
            预测的得分 ∇_x log p(x)
        """
        score, _ = self.forward(x)
        return score
    
    def langevin_dynamics_sample(self, n_samples, n_steps=100, step_size=0.01):
        """
        使用朗之万动力学从模型中采样
        x_{t+1} = x_t + (step_size/2) * s_θ(x_t) + sqrt(step_size) * ε
        
        这是基于得分的生成方法
        参数:
            n_samples: 生成的样本数
            n_steps: 朗之万动力学的步数
            step_size: 步长
        返回:
            生成的样本
        """
        # 从先验分布(标准正态)初始化
        x = np.random.randn(n_samples, self.n_dim) * 3.0
        
        for step in range(n_steps):
            # 计算当前得分
            score = self.predict_score(x)
            # 朗之万更新
            noise = np.random.randn(*x.shape)
            x = x + (step_size / 2) * score + np.sqrt(step_size) * noise
            
            # 可选:逐步退火步长(改善采样质量)
            if step == n_steps // 2:
                step_size *= 0.5  # 后半程减半步长
        
        return x


# ============ 实验 ============
np.random.seed(42)

# 生成混合高斯数据
def generate_mog_data(n_samples):
    """
    生成双峰高斯混合数据
    混合权重各0.5,两个分量的均值不同
    """
    data = []
    for _ in range(n_samples):
        if np.random.random() < 0.5:
            # 分量1:均值 [-2, 0]
            sample = np.random.randn(2) * 0.5 + np.array([-2, 0])
        else:
            # 分量2:均值 [2, 0]
            sample = np.random.randn(2) * 0.5 + np.array([2, 0])
        data.append(sample)
    return np.array(data)

train_data = generate_mog_data(2000)

# 训练模型(测试不同噪声标准差)
for sigma in [0.05, 0.1, 0.5]:
    print(f"\n{'='*50}")
    print(f"噪声标准差 σ = {sigma}")
    print(f"{'='*50}")
    
    model = DenoisingScoreMatching(
        n_dim=2, 
        hidden_dim=32, 
        noise_std=sigma, 
        learning_rate=0.001
    )
    model.train(train_data, epochs=100, batch_size=64)
    
    # 采样生成
    samples = model.langevin_dynamics_sample(n_samples=500, n_steps=200, step_size=0.01)
    print(f"  生成样本均值: {np.mean(samples, axis=0).round(3)}")
    print(f"  生成样本标准差: {np.std(samples, axis=0).round(3)}")

七、知识点 6:噪声对比估计(NCE)

7.1 核心思想

NCE 将密度估计问题转化为一个二分类问题

  1. 定义带参数的未归一化模型 pmodel(x;θ)=p~model(x;θ)p_\text{model}(\mathbf{x}; \theta) = \tilde{p}_\text{model}(\mathbf{x}; \theta)pmodel(x;θ)=p~model(x;θ)ZZZ 被吸收进参数)
  2. 引入一个已知的噪声分布 pnoise(x)p_\text{noise}(\mathbf{x})pnoise(x)
  3. 生成"混合"数据:真实数据(标签 y=1y=1y=1)和噪声样本(标签 y=0y=0y=0
  4. 训练分类器区分两者

关键公式

p(y=1∣x)=pmodel(x)pmodel(x)+k⋅pnoise(x)p(y=1|\mathbf{x}) = \frac{p_\text{model}(\mathbf{x})}{p_\text{model}(\mathbf{x}) + k \cdot p_\text{noise}(\mathbf{x})}p(y=1∣x)=pmodel(x)+kpnoise(x)pmodel(x)

其中 kkk 是每个真实样本对应的噪声样本数。

NCE 目标函数(每个真实样本的贡献)

Ji=log⁡p(y=1∣xi)+k⋅Ex′∼pnoise[log⁡p(y=0∣x′)]J_i = \log p(y=1 | \mathbf{x}_i) + k \cdot \mathbb{E}_{\mathbf{x}' \sim p_\text{noise}} [\log p(y=0 | \mathbf{x}')]Ji=logp(y=1∣xi)+kExpnoise[logp(y=0∣x)]

关键优势:如果将模型写为 pmodel(x)=1Z(θ)p~(x;θ)p_\text{model}(\mathbf{x}) = \frac{1}{Z(\theta)} \tilde{p}(\mathbf{x}; \theta)pmodel(x)=Z(θ)1p~(x;θ),NCE 的梯度会驱使 Z(θ)→1Z(\theta) \to 1Z(θ)1,因此 ZZZ 也被隐式学习了。

7.2 案例代码

import numpy as np

# ============================================
# 知识点6:噪声对比估计 (Noise Contrastive Estimation, NCE)
# ============================================

class NCEModel:
    """
    噪声对比估计模型
    
    目标:估计一个概率分布 p_model(x) = p̃(x; θ) / Z(θ)
    方法:通过区分真实数据和噪声样本来间接学习 θ 和 Z
    
    模型假设:
    - p̃(x; θ) 使用指数族形式
    - 噪声分布 p_noise 选用已知的简单分布(如高斯)
    """
    
    def __init__(self, n_dim, noise_mean=None, noise_cov=None, 
                 learning_rate=0.01, n_noise_samples=10):
        """
        初始化NCE模型
        参数:
            n_dim: 数据维度
            noise_mean: 噪声分布的均值(默认零向量)
            noise_cov: 噪声分布的协方差(默认单位阵)
            learning_rate: 学习率
            n_noise_samples: 每个真实样本对应的噪声样本数 k
        """
        self.n_dim = n_dim
        self.lr = learning_rate
        self.k = n_noise_samples  # 噪声样本倍数
        
        # 模型参数:使用二次型能量模型
        # log p̃(x) = -0.5 * x^T A x - b^T x + c
        # A 必须正定以保证概率可积
        self.A = np.eye(n_dim) * 0.5  # 初始化为半倍单位阵
        self.b = np.zeros(n_dim)       # 偏置
        self.log_Z = 0.0               # log配分函数(NCE会自动学习它!)
        
        # 噪声分布参数
        if noise_mean is None:
            self.noise_mean = np.zeros(n_dim)
        else:
            self.noise_mean = noise_mean
        if noise_cov is None:
            self.noise_cov = np.eye(n_dim) * 4.0  # 较大方差的噪声
        else:
            self.noise_cov = noise_cov
        # 预计算噪声分布精度矩阵和归一化常数
        self.noise_precision = np.linalg.inv(self.noise_cov)
        self.noise_log_norm = -0.5 * (n_dim * np.log(2 * np.pi) + 
                                       np.log(np.linalg.det(self.noise_cov)))
    
    def log_unnormalized_model(self, x):
        """
        计算模型的未归一化对数概率
        log p̃(x; θ) = -0.5 * x^T A x - b^T x
        参数:
            x: 数据,形状 (batch_size, n_dim)
        返回:
            log p̃(x) 的值,形状 (batch_size,)
        """
        return -0.5 * np.sum((x @ self.A) * x, axis=1) - x @ self.b
    
    def log_model_prob(self, x):
        """
        计算模型的(近似)对数概率
        log p_model(x) ≈ log p̃(x) - log Z
        在NCE中,log Z 也被学习
        参数:
            x: 数据
        返回:
            log p_model(x)
        """
        return self.log_unnormalized_model(x) - self.log_Z
    
    def log_noise_prob(self, x):
        """
        计算噪声分布的对数概率
        log p_noise(x) = -0.5 * (x-μ)^T Σ^{-1} (x-μ) + log_norm
        参数:
            x: 数据,形状 (batch_size, n_dim)
        返回:
            log p_noise(x),形状 (batch_size,)
        """
        diff = x - self.noise_mean  # 偏差
        # 二次型
        quad = np.sum((diff @ self.noise_precision) * diff, axis=1)
        return -0.5 * quad + self.noise_log_norm
    
    def nce_probability(self, x, log_p_model, log_p_noise):
        """
        计算NCE分类概率 p(y=1|x)
        
        p(y=1|x) = p_model(x) / (p_model(x) + k * p_noise(x))
        
        在对数空间中实现以避免数值溢出:
        log_ratio = log p_model(x) - log p_noise(x)
        p(y=1|x) = sigmoid(log_ratio - log(k))
        
        参数:
            x: 数据
            log_p_model: log p_model(x)
            log_p_noise: log p_noise(x)
        返回:
            p(y=1|x) 的值
        """
        # log(p_model/p_noise) - log(k) = log_ratio - log(k)
        log_ratio = log_p_model - log_p_noise - np.log(self.k)
        # sigmoid
        return 1.0 / (1.0 + np.exp(-np.clip(log_ratio, -500, 500)))
    
    def nce_loss_and_gradient(self, real_data):
        """
        计算NCE损失和梯度
        
        损失函数(对每个真实样本x_i和k个噪声样本x'_j):
        L_i = -log p(y=1|x_i) - sum_j log p(y=0|x'_j)
        
        总损失 L = (1/N) * sum_i L_i
        
        梯度通过链式法则计算
        参数:
            real_data: 真实数据,形状 (batch_size, n_dim)
        返回:
            loss: 损失值
            grad_A, grad_b, grad_log_Z: 各参数梯度
        """
        batch_size = real_data.shape[0]
        
        # === 生成噪声样本 ===
        # 从噪声分布中为每个真实样本采样k个噪声样本
        noise_samples = np.random.multivariate_normal(
            self.noise_mean, self.noise_cov, 
            size=(batch_size, self.k)
        )  # 形状 (batch_size, k, n_dim)
        # 重塑为 (batch_size * k, n_dim)
        noise_flat = noise_samples.reshape(-1, self.n_dim)
        
        # === 计算对数概率 ===
        # 真实数据
        log_p_real = self.log_model_prob(real_data)       # 形状 (batch_size,)
        log_q_real = self.log_noise_prob(real_data)        # 形状 (batch_size,)
        
        # 噪声样本
        log_p_noise = self.log_model_prob(noise_flat)      # 形状 (batch_size * k,)
        log_q_noise = self.log_noise_prob(noise_flat)      # 形状 (batch_size * k,)
        
        # === 计算NCE分类概率 ===
        p_real = self.nce_probability(real_data, log_p_real, log_q_real)    # p(y=1|真实数据)
        p_noise = self.nce_probability(noise_flat, log_p_noise, log_q_noise) # p(y=1|噪声样本)
        
        # === 损失计算 ===
        # -log p(y=1|x_real) - sum log p(y=0|x_noise)
        # = -log p(y=1|x_real) - sum log(1-p(y=1|x_noise))
        loss_real = -np.mean(np.log(p_real + 1e-10))      # 真实数据部分
        loss_noise = -np.mean(np.log(1 - p_noise + 1e-10)) # 噪声数据部分
        loss = loss_real + loss_noise
        
        # === 梯度计算 ===
        # 关键推导:
        # 对于真实样本:∂L/∂θ = -(1 - p(y=1|x_real)) * ∂log p̃(x_real)/∂θ
        # 对于噪声样本:∂L/∂θ = p(y=1|x_noise) * ∂log p̃(x_noise)/∂θ
        
        # 真实数据的梯度权重
        weight_real = (1 - p_real) / batch_size  # 形状 (batch_size,)
        
        # 噪声数据的梯度权重
        weight_noise = p_noise / (batch_size * self.k)  # 形状 (batch_size * k,)
        
        # log p̃(x) = -0.5 x^T A x - b^T x
        # ∂log p̃/∂A = -0.5 * x x^T  (矩阵形式)
        # ∂log p̃/∂b = -x
        
        # A 的梯度
        # 真实数据贡献
        grad_A_real = np.zeros_like(self.A)
        for i in range(batch_size):
            grad_A_real += weight_real[i] * (-0.5 * np.outer(real_data[i], real_data[i]))
        
        # 噪声数据贡献
        grad_A_noise = np.zeros_like(self.A)
        for i in range(len(noise_flat)):
            grad_A_noise += weight_noise[i] * (-0.5 * np.outer(noise_flat[i], noise_flat[i]))
        
        grad_A = grad_A_real + grad_A_noise  # 总梯度
        
        # b 的梯度
        grad_b_real = -real_data.T @ weight_real  # 形状 (n_dim,)
        grad_b_noise = -noise_flat.T @ weight_noise  # 形状 (n_dim,)
        grad_b = grad_b_real + grad_b_noise
        
        # log_Z 的梯度
        # NCE的独特之处:它也学习Z!
        # ∂L/∂log_Z = (1/N) * sum[(1-p(y=1|x_real))] - (k/N) * sum[p(y=1|x_noise)]
        # 简化理解:当模型概率过高时,log_Z增大;反之减小
        grad_log_Z = np.mean(1 - p_real) - np.mean(p_noise)
        
        return loss, grad_A, grad_b, grad_log_Z
    
    def train(self, data, epochs=300, batch_size=64):
        """
        使用NCE训练模型
        参数:
            data: 真实训练数据
            epochs: 训练轮数
            batch_size: 批大小
        """
        n_samples = data.shape[0]
        print(f"开始NCE训练 (k={self.k}, 噪声方差={np.diag(self.noise_cov).mean():.2f})...")
        
        for epoch in range(epochs):
            indices = np.random.permutation(n_samples)
            data_shuffled = data[indices]
            
            total_loss = 0
            n_batches = 0
            
            for i in range(0, n_samples, batch_size):
                batch = data_shuffled[i:i + batch_size]
                
                loss, grad_A, grad_b, grad_log_Z = self.nce_loss_and_gradient(batch)
                
                # 梯度下降(最小化损失)
                self.A -= self.lr * grad_A
                self.b -= self.lr * grad_b
                self.log_Z -= self.lr * grad_log_Z
                
                # 确保A对称
                self.A = (self.A + self.A.T) / 2
                
                total_loss += loss
                n_batches += 1
            
            if (epoch + 1) % 50 == 0:
                print(f"  Epoch {epoch+1}/{epochs}, "
                      f"损失: {total_loss/n_batches:.4f}, "
                      f"log_Z: {self.log_Z:.4f}")


# ============ NCE实验 ============
np.random.seed(42)

# 生成二维高斯数据
# 真实分布 N([1, -1], [[1.5, 0.3], [0.3, 0.8]])
true_mean = np.array([1.0, -1.0])
true_cov = np.array([[1.5, 0.3], [0.3, 0.8]])
train_data = np.random.multivariate_normal(true_mean, true_cov, size=1000)

# 设置NCE
# 噪声分布选一个较宽的高斯,覆盖数据所在区域
noise_cov = np.eye(2) * 5.0  # 大方差

model = NCEModel(
    n_dim=2, 
    noise_mean=np.zeros(2), 
    noise_cov=noise_cov,
    learning_rate=0.005, 
    n_noise_samples=20  # 每个真实样本对应20个噪声样本
)

model.train(train_data, epochs=300, batch_size=64)

# 结果比较
true_precision = np.linalg.inv(true_cov)
print(f"\n真实精度矩阵:\n{np.round(true_precision, 4)}")
print(f"学到的精度矩阵 A:\n{np.round(model.A, 4)}")
print(f"真实 log Z: {0.5*(np.log(np.linalg.det(true_cov)) + 2*np.log(2*np.pi)):.4f}")
print(f"学到的 log Z: {model.log_Z:.4f}")
print(f"\n真实均值: {true_mean}")
print(f"模型隐含均值: {np.round(-np.linalg.solve(model.A, model.b), 4)}")

八、知识点 7:估计配分函数

8.1 核心思想

有时我们仍然需要直接估计 ZZZ(例如计算似然值或进行模型比较)。

直接方法:对离散空间,遍历所有状态:
Z=∑xp~(x)Z = \sum_\mathbf{x} \tilde{p}(\mathbf{x})Z=xp~(x)

但这通常不可行(状态数指数增长)。

主要方法:

方法思想特点
桥式采样利用两个分布之间的样本比率需要从两个分布都能采样
退火重要性采样 (AIS)p0p_0p0pmodelp_\text{model}pmodel 之间构造一系列中间分布最常用,通过 MCMC 在各中间分布之间转移
变分上界利用 log⁡Z\log ZlogZ 的上界进行估计给出 log⁡Z\log ZlogZ 的上界估计

AIS 的核心思想

构造温度序列 0=β0<β1<⋯<βT=10 = \beta_0 < \beta_1 < \cdots < \beta_T = 10=β0<β1<<βT=1,定义中间分布:

pβt(x)=p~model(x)βt⋅p0(x)1−βtZβtp_{\beta_t}(\mathbf{x}) = \frac{\tilde{p}_\text{model}(\mathbf{x})^{\beta_t} \cdot p_0(\mathbf{x})^{1-\beta_t}}{Z_{\beta_t}}pβt(x)=Zβtp~model(x)βtp0(x)1βt

其中 p0p_0p0 是已知配分函数的简单分布。

利用重要性采样的比率关系:

Z1Z0=Ep0[∏t=1Twtwt−1]\frac{Z_1}{Z_0} = \mathbb{E}_{p_0} \left[ \prod_{t=1}^{T} \frac{w_t}{w_{t-1}} \right]Z0Z1=Ep0[t=1Twt1wt]

8.2 案例代码:退火重要性采样(AIS)

import numpy as np

# ============================================
# 知识点7:退火重要性采样 (AIS) 估计配分函数
# ============================================

class AISPartitionEstimator:
    """
    使用退火重要性采样(AIS)估计基于能量模型的配分函数
    
    AIS 原理:
    1. 选择一个已知Z的简单分布 p_0 (如高斯)
    2. 在 p_0 和 p_model 之间插值一系列分布
    3. 从 p_0 开始,依次在各插值分布间做MCMC转移
    4. 累积重要性权重来估计 Z_model/Z_0
    """
    
    def __init__(self, n_dim, energy_fn, beta_schedule=None):
        """
        初始化AIS估计器
        参数:
            n_dim: 数据维度
            energy_fn: 能量函数 E(x),可调用
            beta_schedule: 退火温度调度 [0, β1, β2, ..., 1]
        """
        self.n_dim = n_dim
        self.energy_fn = energy_fn
        
        # 设置退火调度:如果没有指定,使用均匀间隔的100个温度
        if beta_schedule is None:
            self.betas = np.linspace(0, 1, 100)
        else:
            self.betas = beta_schedule
        
        # 初始分布 p_0: 标准正态 N(0, I)
        # 其配分函数 Z_0 = (2π)^{d/2}
        self.log_Z_0 = 0.5 * n_dim * np.log(2 * np.pi)
    
    def log_p_beta(self, x, beta):
        """
        计算退火分布的(未归一化)对数概率
        
        log p_β(x) = -β * E(x) - (1-β) * 0.5 * ||x||²
                   = -β * E(x) - (1-β) * E_0(x)
        
        其中 E_0(x) = 0.5 * ||x||² 是标准正态的能量
        
        参数:
            x: 状态,形状 (n_dim,) 或 (batch, n_dim)
            beta: 退火温度参数
        返回:
            log p_β(x)(未归一化)
        """
        log_p_model = -self.energy_fn(x)     # log p̃_model(x)
        log_p_0 = -0.5 * np.sum(x ** 2)      # log p_0(x) (标准正态的未归一化对数概率)
        
        return beta * log_p_model + (1 - beta) * log_p_0
    
    def log_weight_ratio(self, x, beta_from, beta_to):
        """
        计算从 beta_from 到 beta_to 的重要性权重比
        
        log(w_new/w_old) = log p_{β_to}(x) - log p_{β_from}(x)
        
        参数:
            x: 当前状态
            beta_from: 起始温度
            beta_to: 目标温度
        返回:
            log 权重比
        """
        return self.log_p_beta(x, beta_to) - self.log_p_beta(x, beta_from)
    
    def mcmc_step(self, x, beta, step_size=0.1):
        """
        在退火分布 p_β 上执行一步Metropolis-Hastings MCMC
        
        提议分布:高斯随机游走 q(x'|x) = N(x; x, step_size² I)
        接受概率:α = min(1, p_β(x')/p_β(x))
        
        参数:
            x: 当前状态,形状 (n_dim,)
            beta: 当前退火温度
            step_size: 提议分布的标准差
        返回:
            x_new: 新状态(可能被拒绝,等于x)
            accepted: 是否接受
        """
        # 提议新状态:在当前状态附近高斯采样
        x_proposal = x + np.random.randn(self.n_dim) * step_size
        
        # 计算接受比(对数空间计算防止溢出)
        log_accept_ratio = (self.log_p_beta(x_proposal, beta) - 
                           self.log_p_beta(x, beta))
        
        # Metropolis接受/拒绝
        if np.log(np.random.uniform()) < log_accept_ratio:
            return x_proposal, True   # 接受
        else:
            return x, False            # 拒绝
    
    def run_ais(self, n_chains=100, n_mcmc_steps=10, step_size=0.5):
        """
        运行AIS算法
        
        流程:
        1. 从 p_0 初始化 n_chains 条链
        2. 对每个退火阶段 t=1,...,T:
           a. 更新温度从 β_{t-1} 到 β_t
           b. 累积重要性权重
           c. 在 p_{β_t} 上做MCMC步以更新样本
        3. 利用样本权重估计 Z_model/Z_0
        
        参数:
            n_chains: 并行运行的MCMC链数(越多估计越稳定)
            n_mcmc_steps: 每个温度阶段的MCMC步数
            step_size: MCMC步长
        返回:
            log_Z_estimate: log Z 的估计值
            log_Z_std: log Z 估计的标准差(不确定性)
        """
        # 步骤1:从初始分布 p_0 = N(0, I) 采样
        chains = np.random.randn(n_chains, self.n_dim)  # (n_chains, n_dim)
        
        # 初始化 log 权重(每条链独立)
        log_weights = np.zeros(n_chains)
        
        # 步骤2:逐步退火
        for t in range(1, len(self.betas)):
            beta_prev = self.betas[t - 1]  # 上一个温度
            beta_curr = self.betas[t]       # 当前温度
            beta_diff = beta_curr - beta_prev  # 温度增量
            
            for chain_idx in range(n_chains):
                # 2a: 更新重要性权重
                # w_t = w_{t-1} * p_{β_t}(x) / p_{β_{t-1}}(x)
                # 即 log w_t = log w_{t-1} + log_ratio
                log_weight_change = self.log_weight_ratio(
                    chains[chain_idx], beta_prev, beta_curr
                )
                log_weights[chain_idx] += log_weight_change
                
                # 2b: 在 p_{β_t} 上执行 MCMC 步
                for _ in range(n_mcmc_steps):
                    chains[chain_idx], _ = self.mcmc_step(
                        chains[chain_idx], beta_curr, step_size
                    )
        
        # 步骤3:估计 log(Z_model/Z_0)
        # 使用 log-sum-exp 技巧数值稳定地计算
        # log(mean(exp(log_weights))) = log_sum_exp(log_weights) - log(n_chains)
        max_log_w = np.max(log_weights)
        log_mean_weights = (max_log_w + 
                           np.log(np.mean(np.exp(log_weights - max_log_w))))
        
        # log Z_model = log Z_0 + log(mean(weights))
        log_Z_estimate = self.log_Z_0 + log_mean_weights
        
        # 使用 bootstrap 估计标准差
        n_bootstrap = 1000
        bootstrap_estimates = []
        for _ in range(n_bootstrap):
            # 有放回重采样
            boot_indices = np.random.choice(n_chains, size=n_chains, replace=True)
            boot_weights = log_weights[boot_indices]
            boot_max = np.max(boot_weights)
            boot_log_mean = boot_max + np.log(np.mean(np.exp(boot_weights - boot_max)))
            bootstrap_estimates.append(self.log_Z_0 + boot_log_mean)
        
        log_Z_std = np.std(bootstrap_estimates)
        
        return log_Z_estimate, log_Z_std


class ToyIsingModel:
    """
    二维 Ising 模型(玩具版)
    在 L x L 的方格上,每个格点有自旋 σ_i ∈ {-1, +1}
    能量:E(σ) = -J * sum_{<i,j>} σ_i * σ_j - h * sum_i σ_i
    其中 <i,j> 表示最近邻对
    """
    
    def __init__(self, L, J=1.0, h=0.0):
        """
        初始化Ising模型
        参数:
            L: 方格边长(总格点数 L^2)
            J: 耦合强度(正值=铁磁,负值=反铁磁)
            h: 外磁场强度
        """
        self.L = L          # 方格边长
        self.n = L * L      # 总格点数
        self.J = J          # 耦合常数
        self.h = h          # 外场
        
        # 预计算最近邻对的列表
        self.neighbors = []
        for i in range(L):
            for j in range(L):
                idx = i * L + j  # 当前格点的一维索引
                # 右邻居(周期性边界)
                right = i * L + (j + 1) % L
                # 下邻居(周期性边界)
                down = ((i + 1) % L) * L + j
                self.neighbors.append((idx, right))
                self.neighbors.append((idx, down))
    
    def energy(self, spins):
        """
        计算Ising模型能量
        E = -J * sum_{<i,j>} σ_i σ_j - h * sum_i σ_i
        
        参数:
            spins: 自旋配置,形状 (n,) 或 (batch, n),值为 ±1
        返回:
            能量值
        """
        if spins.ndim == 1:
            spins = spins.reshape(1, -1)
        
        batch_size = spins.shape[0]
        energy = np.zeros(batch_size)
        
        # 交互项
        for (i, j) in self.neighbors:
            energy -= self.J * spins[:, i] * spins[:, j]
        
        # 外场项
        energy -= self.h * np.sum(spins, axis=1)
        
        return energy
    
    def exact_log_partition(self):
        """
        计算精确的 log Z(仅适用于小系统)
        遍历所有 2^n 个状态
        仅当 n <= 20 时可行
        """
        if self.n > 20:
            print(f"警告:系统太大 (n={self.n}),精确计算不可行")
            return None
        
        log_Z = -np.inf  # 初始化为负无穷
        total_states = 2 ** self.n
        
        # 使用 log-sum-exp 技巧
        log_unnorm_probs = []
        
        for state_idx in range(total_states):
            # 将整数转换为二进制自旋配置
            # 0 → -1, 1 → +1
            bits = np.array([(state_idx >> k) & 1 for k in range(self.n)])
            spins = 2 * bits - 1  # 转换为 ±1
            
            # 计算该状态的能量
            E = self.energy(spins.reshape(1, -1))[0]
            log_unnorm_probs.append(-E)  # log p̃ = -E
        
        log_unnorm_probs = np.array(log_unnorm_probs)
        # log Z = log sum exp(log_p̃) = max + log sum exp(log_p̃ - max)
        max_val = np.max(log_unnorm_probs)
        log_Z = max_val + np.log(np.sum(np.exp(log_unnorm_probs - max_val)))
        
        return log_Z


# ============ AIS 实验 ============
np.random.seed(42)

# 创建小规模Ising模型(4x4 = 16个格点)
L = 4
ising = ToyIsingModel(L=L, J=0.5, h=0.1)  # 铁磁耦合+弱外场

print(f"Ising 模型: {L}x{L} 格点, J={ising.J}, h={ising.h}")

# 计算精确配分函数
exact_log_Z = ising.exact_log_partition()
print(f"\n精确 log Z: {exact_log_Z:.6f}")

# 使用AIS估计配分函数
print("\n运行AIS估计...")
ais = AISPartitionEstimator(
    n_dim=ising.n,
    energy_fn=ising.energy,
    beta_schedule=np.linspace(0, 1, 200)  # 200个退火阶段
)

# 多次运行AIS以评估稳定性
n_trials = 5
ais_estimates = []

for trial in range(n_trials):
    log_Z_est, log_Z_std = ais.run_ais(
        n_chains=500,        # 500条并行链
        n_mcmc_steps=5,      # 每阶段5步MCMC
        step_size=1.0        # MCMC步长
    )
    ais_estimates.append(log_Z_est)
    print(f"  Trial {trial+1}: log Z = {log_Z_est:.4f} ± {log_Z_std:.4f}")

ais_mean = np.mean(ais_estimates)
ais_std = np.std(ais_estimates)
print(f"\nAIS 估计: log Z = {ais_mean:.4f} ± {ais_std:.4f}")
print(f"精确值:   log Z = {exact_log_Z:.6f}")
print(f"绝对误差: {abs(ais_mean - exact_log_Z):.6f}")
print(f"相对误差: {abs(ais_mean - exact_log_Z) / abs(exact_log_Z) * 100:.4f}%")

九、知识总结对比表

方法是否需要 Z适用空间计算复杂度梯度是否有偏
对数似然梯度需要采样近似连续/离散采样引入方差
CD-1不需要离散为主有偏
SML/PCD不需要离散为主渐近无偏
伪似然不需要离散低 (O(n))无偏但目标不同
得分匹配不需要连续中(需雅可比)无偏
去噪得分匹配不需要连续无偏
比率匹配不需要离散高 (O(n))无偏
NCE隐式学习 Z连续/离散渐近无偏
AIS直接估计 Z通用有偏但可控

每个方法都是在计算可行性估计质量之间做权衡。实践中,CD-1 在 RBM 中最常用,NCE 在语言模型中有重要应用,去噪得分匹配在生成模型中越来越受欢迎(如 score-based diffusion models)。

更多推荐