LAB 07 · 图像 VAE:重建与可用起点
先阅读保存结果和解释,再按本册步骤选择是否运行。在 Deepnote 阅读与运行 · 下载 Notebook
LAB07|一个图像 VAE 怎样同时学重建与可用起点
你在课堂上已经看过两条路径:把一张图像编码后再解码,以及直接抽一个潜变量再解码。本实验把这两条路径放进同一个真正训练的小网络。先看保存结果,再读它学了什么;最后只改一个条件,重新运行。这里使用公开的 UCI 8×8 手写数字,由 scikit-learn 自带数据提供,不是 MNIST。所有展示均来自本次 NumPy 训练,没有用生成式插画替换模型输出。
本册承接 N15–N20 与 CLS03。LAB02 用可解的高斯例子解释 q、后验与 ELBO;本册则让 1,437 张训练图像共用一个编码器和一个解码器,检查学到的参数是否能处理 360 张未用于更新的测试图像。
1. 先看学习究竟改变了什么

从上往下读一列。第一行是公开数据原来的灰度图,第二行是本实验真正输入的二值图:原像素值大于 8 记为 1,否则记为 0。第三行是随机初始化模型的输出。最后两行分别来自 β=0、β=1 的训练。先找数字 0 的空心区域,再看数字 3、8 是否仍容易分辨:学习已经让输出产生笔画结构,但两维潜变量和小模型也丢失了细节。它没有把全部测试数字都清楚重建出来。
这张图把编码器输出的均值 μ(x) 送入解码器,用来做稳定的重建对照。灰度值是每个二值像素取 1 的概率,不是二值像素本身;decoder(mu) 也不等于对所有后验 z 求平均的 E_q[decoder(z)]。训练时并没有固定取 μ,而是随机抽取 z。
2. 已经能重建,为什么还不能随便取一个 z?

这一次没有输入数字图像。我们先从 N(0,I) 取出固定的 25 个二维向量,图中展示前十个,再把同一组 z 交给初始化、β=0 和 β=1 三个解码器。重建时起点来自“这张图被编码到哪里”;现在起点来自预先约定的标准正态分布。两种起点没有理由天然一致。
这正是本册的模拟学生追问:“我已经能把输入重建出来,为什么还不能随便取一个 z?”先别用“因为 KL”结束回答。请看下一组图:彩色点是 360 张测试图的后验均值,颜色仅用于显示原数字标签,标签没有进入训练损失;黑叉是刚才那 25 个标准正态起点。虚线圆只是半径 2 的尺度参照,不是所有先验概率的边界。两幅左侧散点图按各自数据范围定轴;同一个半径 2 圆在屏幕上大小不同,先读轴刻度再比较区域,不能把像素距离当成潜坐标距离。
![β=0后验均值与固定先验起点的位置;右边是[-3,3]平方区域的均匀潜空间网格解码结果。](course/experiments/vae/latent_beta0.png)

β=0 时,编码器把图像送到的区域明显移出了标准正态起点常见的尺度:后验均值距原点的平均距离约 12.32。模型能够在那些区域支持重建,却没有被要求照顾 N(0,I) 经常选到的区域。β=1 时,平均距离约为 1.28,两类起点的尺度更接近。因而先验入口的差异有了可观察的原因,不只是一个正则项名称。
右边的网格把 z₁、z₂ 均匀扫过 [-3,3],每个格子显示一个解码概率图。它帮助我们看解码器在这块区域如何变化,不是从高斯先验独立采样出来的图阵。左图也只显示后验均值;完整 q 还有方差,不能把均值散点当成整个聚合后验分布。
β=1 的先验输出仍有明显模糊和重复,并没有证明十类数字已均匀覆盖。这里观察到的是重建与先验入口之间的一次真实权衡,不是“大 β 的图片一定更好”。
3. 两组训练只改变 β
固定数据划分、网络、初始参数、每个 epoch 的样本顺序、重参数化噪声、优化器、训练次数。每组都训练 150 个 epoch,每批最多 64 张图,共 3,450 次更新。β=0、β=1 各采用一个预先固定的随机设置,没有搜索超参数,也没有按输出好坏换种子;另在独立进程执行 Notebook 代码复核,同设置得到完全相同的模型与观察数组。编码器是 64→64→(μ₂,logσ²₂),解码器是 2→64→64,两边共享参数总数为 8,772。
| 固定测试集上的量 | 初始化 | β=0,训练后 | β=1,训练后 |
|---|---|---|---|
| 重建负对数似然,nats/图 | 44.875 | 16.061 | 18.638 |
| KL(q(z|x)|N(0,I)),nats/图 | 0.454 | 103.375 | 2.258 |
| 后验均值距原点的平均距离 | — | 12.323 | 1.285 |
| 后验标准差的平均值 | — | 0.0098 | 0.3496 |
重建项是 64 个像素求和,再对图像和 8 组固定后验噪声取平均;KL 是两个潜变量维度求和,再对图像平均。这样 β 的尺度才有明确含义。β=0 的重建项更低,但 KL 大幅增加;β=1 为兼顾先验入口付出了一些重建代价。不要直接比较两个不同权重目标的总损失并宣布一个更好。

本次 CPU 上,每组训练连同周期性测试评估约 1.6 秒;图像绘制与文件导出不计入这两个时间。运行时间只记录本次环境,不承诺其他机器同速。
4. 网络究竟学什么,随机性怎样进入梯度?
编码器读取二值图像 x,给出两维 μ(x) 和 logσ²(x),定义近似后验 qφ(z|x)。解码器读取 z,输出 64 个概率 πθ(z),定义条件独立的 Bernoulli 观测模型。标准先验 p(z)=N(0,I) 固定,没有待学习参数。网络学的是两组共享权重 φ、θ,不是为每张图分别存一套解码器。
训练时先抽 ε∼N(0,I),再计算 z=μ+σ⊙ε。一次训练步把这次 ε 看作固定输入,损失便能沿 z 对 μ、σ 求导。σ 并未被绕过去:对 logσ² 的重建梯度包含 (dL/dz) * epsilon * 0.5 * sigma。这就是本实现的重参数化路径。
我们最小化的批次目标为:
$$J_\beta = \frac{1}{B}\sum_i\left[-\sum_{j=1}^{64}\log p_\theta(x_{ij}|z_i)+\beta\,\frac12\sum_{k=1}^{2}(\mu_{ik}^2+\sigma_{ik}^2-1-\log\sigma_{ik}^2)\right].$$
每个样本每次更新使用一个后验 z。β=1 时,它是标准负 ELBO 的蒙特卡洛估计;β=0 则只保留随机编码下的重建目标。β=0 仍然抽取 z,所以不能直接把它改称确定性 AE。 本次它学到的 σ 很小,但“很小”不等于实现中完全没有抽样。
用一个像素核对损失:若真实二值像素为 1,模型预测它为 1 的概率是 0.8,这个像素贡献 −log(0.8)≈0.223 nats;若预测 0.2,则贡献约 1.609 nats。模型靠重复更新共享参数,把这类损失反馈到解码器、z,再到编码器。
5. 后验抽样、插值和先验抽样不是同一操作

每一行只固定一张输入图。编码器先给出这一张图的 μ、σ;改变 ε 会得到同一 q(z|x) 下不同的 z,再产生不同概率图。因此它仍然借助输入图像,不是无输入的先验生成。观察一行内部能否保留同一数字的笔画,再与其他行比较;本次有些数字仍模糊,不能把每张输出都当作识别正确的样本。

插值则先编码两张端点图,再计算 z(α)=(1−α)μ(x₃)+αμ(x₈)。中间点是我们指定的路径,不是后验抽样,也不是先验抽样。平滑变化说明这个解码器沿这条路径连续响应;它不保证中间每个图都属于清楚可辨的数字,也不证明潜空间的任意方向都有相同语义。
| 操作 | 已经给定什么 | 改变或抽取什么 | 本图显示什么 |
|---|---|---|---|
| 稳定重建 | 一张 x | 取 z=μ(x) | decoder(μ) 的概率图 |
| 后验抽样 | 一张 x 与 qφ(z|x) | ε,继而 z | 不同 z 的解码概率图 |
| 潜变量插值 | 两张端点图的 μ | 路径位置 α | 指定路径上的概率图 |
| 先验生成 | 固定 p(z) | z∼N(0,I) | 无输入图像的概率图 |
| 潜空间网格 | 坐标范围与格点 | 按网格扫坐标 | 均匀格点的概率图 |
6. 概率图之后,还能再抽一次观测

上行一个灰色像素的值若为 0.7,它表示“这个像素取 1 的概率为 0.7”。下行用同一张概率图和固定均匀随机数 u,按 u < probability 得到真正的二值观测。下行更粗糙是这层观测抽样的直接结果,不能说它用了另一个 z 或另一个解码器。
所以“从 VAE 生成图像”至少要交代显示的是哪一层。这里常用概率图,是为了稳定比较 z 和模型变化;若声称从整个生成模型抽样,就还包含 Bernoulli 观测这一步。
7. 读实现,然后在独立进程中重算
完整实现放在同目录 lab07_image_vae.py,只需要 NumPy、SciPy、scikit-learn、Matplotlib 与 threadpoolctl。数据由 scikit-learn 自带,无须在线下载;没有其他 Notebook 留下的隐藏变量。运行 python lab07_image_vae.py --out results 会从初始化开始训练两组模型,保存数据划分、初始和最终权重、固定测试输入、逐期曲线、原始观察数组与全部图片。
forward 对像素的求和、对批次的平均和 KL 梯度都显式写出。运行前会固定 ε,对 β=0 与 β=1 两条路径、每个参数张量的一些元素作中心有限差分核验;本次最大缩放误差见 results/gradient_check.json。这项核验验证梯度实现的局部一致性,不替代生成效果评价。
保存结果来自一次独立 Python 进程执行;本册的 Notebook 包含同一份实现与独立运行入口。浏览保存图不需要启动训练,点击运行才会重新学习参数。参数、原始数组、图像与软件版本都保存在同一个结果目录。
在 Deepnote 重算时怎样读新结果。 从下方实现开始按顺序运行;运行格会直接显示本次目录中的重建、共同先验起点和两组潜空间图。先确认两组共用输入与起点,再分别比较重建项、KL 与先验图。上方图仍是最初保存的基线;修改 β 后,运行格显示的是你的新结果。
云端复核(2026-09-30)。 本册在 Deepnote 的独立内核按自身步骤完整运行。150 epoch 的两组 β 对照完整执行,重建项、KL 和平均潜半径与上面的基线在显示精度内一致;新结果图由运行格直接显示。上方原始保存图继续标为本地基线,两次测量分别记录;大图在插件的运行快照预览中可能因尺寸被省略,这不代替学生在同册查看自己的输出。
8. 只改一个变量,留下可解释的观察
先保留这次结果。把 CONFIG["betas"] 改为 [0.0, 0.5, 1.0],其余设置不动,并输出到另一个目录。先写下你预期改变的两项:重建项、后验与先验的相对位置。再看新结果有没有支持这个预期。图像会按新的 β 列表生成对应行,新增组另存权重和原始观察。本次保存证据只覆盖 β=0 与 1,没有运行 β=0.5。
更直接的单变量练习是运行 python lab07_image_vae.py --out shorter_training --epochs 50,保持两组 β 不变,只改变训练预算。比较的是“150→50 个 epoch”这一干预,不能把较短训练结果当作新的 β 对照。观察完成后,用一段话回答:哪个量真的被学习改变了?哪条生成路径借助输入图像?重建更好是否同时改善了标准先验入口?
自检:如果只展示 decoder(mu),能否说已经检验从 p(z) 生成?不能,因为它的起点仍来自测试输入。若看到先验图阵里十张图相似,能否直接宣布完整模型坍塌?也不能;先记录固定十个起点下的重复现象,再扩大事先定义的观察,区分两维瓶颈、小数据、训练预算与分布覆盖问题。
9. 从结果回到机制
本实验让同一组共享参数实际连接重建与无输入生成,并给出了一个可以指着图解释的断点:重建路径常去的潜空间区域,不必等于先验生成经常访问的区域。 KL 权重改变了这两种需求的权衡,代价和收益都需要分别观察。请回到 CLS03 的 q 与先验部分,或在 LAB02 的可解例子中计算一次 KL,再回来检查你算的量在这张图里对应什么。
原理核对使用 Kingma 与 Welling《Auto-Encoding Variational Bayes》 的重参数化、Gaussian q、Bernoulli decoder 与 Gaussian KL;数据定义使用 scikit-learn 官方 load_digits 文档。本实验是课程自己的轻量实现,不是论文实验指标的复现,也不是用于评价真实高清图像生成的基准。
完整可运行实现
下面的代码就是随附 .py 的同源实现。在新内核中从这里开始运行,下一格会重训;不会读取其他册的变量。上面的图是已经保存的真实实验结果。
查看可执行代码
"""LAB07: genuine small image VAE training, NumPy-only backpropagation.
Run: python lab07_image_vae.py --out results
Requires numpy, scipy, scikit-learn, matplotlib, threadpoolctl. No download, GPU or hidden state.
Uses sklearn's bundled 8x8 UCI digits, NOT MNIST. Pixel > 8 -> 1.
Primary references:
https://arxiv.org/html/1312.6114v11 (sections 2-3, appendices B/C)
https://scikit-learn.org/stable/modules/generated/sklearn.datasets.load_digits.html
"""
import os
for name in ("OPENBLAS_NUM_THREADS", "OMP_NUM_THREADS", "MKL_NUM_THREADS"):
os.environ[name] = "1"
import argparse
import copy
import csv
import hashlib
import json
from pathlib import Path
import platform
import time
import numpy as np
from threadpoolctl import threadpool_limits
import scipy
from scipy.special import expit
import sklearn
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
CONFIG = dict(data="sklearn.datasets.load_digits (UCI 8x8 digits)",
binarization="pixel > 8", train_fraction=0.8,
split_seed=7301, init_seed=7302, train_seed=7303,
evaluation_seed=7304, visual_seed=7305,
architecture="64-tanh64-(mu2,logvar2); z2-tanh64-logits64",
latent_dim=2, hidden_dim=64, epochs=150, batch_size=64,
learning_rate=0.001, betas=[0.0, 1.0],
reconstruction="Bernoulli negative log likelihood: sum 64 pixels, mean batch",
kl="diagonal Gaussian to N(0,I): sum 2 latent dimensions, mean batch",
train_mc_samples=1, evaluation_mc_samples=8,
optimizer="Adam beta1=0.9 beta2=0.999 eps=1e-8",
dtype="float64", version="lab07-v1")
def initialize(seed):
rng = np.random.default_rng(seed)
p = {}
for name, a, b in [("e", 64, 64), ("mu", 64, 2), ("lv", 64, 2),
("d", 2, 64), ("out", 64, 64)]:
p[name+"W"] = rng.normal(0, np.sqrt(2/(a+b)), (a, b))
p[name+"b"] = np.zeros(b)
return p
def encode(p, x):
h = np.tanh(x @ p["eW"] + p["eb"])
return h @ p["muW"] + p["mub"], h @ p["lvW"] + p["lvb"]
def decode(p, z):
return expit(np.tanh(z @ p["dW"] + p["db"]) @ p["outW"] + p["outb"])
def forward(p, x, eps, beta, gradient=False):
h = np.tanh(x @ p["eW"] + p["eb"])
mu, lv = h @ p["muW"] + p["mub"], h @ p["lvW"] + p["lvb"]
sd = np.exp(0.5 * lv)
z = mu + sd * eps # reparameterization, no detached mu/sd
hd = np.tanh(z @ p["dW"] + p["db"])
logits = hd @ p["outW"] + p["outb"]
prob = expit(logits)
recon = np.mean(np.sum(np.logaddexp(0, logits) - x * logits, axis=1))
kl = np.mean(0.5 * np.sum(mu**2 + np.exp(lv) - 1 - lv, axis=1))
result = {"objective": float(recon + beta * kl), "reconstruction_nll": float(recon),
"kl": float(kl), "negative_elbo": float(recon + kl)}
if not gradient:
return result
n = len(x)
g = {}
dl = (prob-x)/n # pixel SUM; batch MEAN
g["outW"], g["outb"] = hd.T @ dl, dl.sum(0)
dhd = (dl @ p["outW"].T) * (1-hd**2)
g["dW"], g["db"] = z.T @ dhd, dhd.sum(0)
dz = dhd @ p["dW"].T
dmu = dz + beta * mu/n
dlv = dz * eps * 0.5 * sd + beta * 0.5 * (np.exp(lv)-1)/n
g["muW"], g["mub"] = h.T @ dmu, dmu.sum(0)
g["lvW"], g["lvb"] = h.T @ dlv, dlv.sum(0)
dh = (dmu @ p["muW"].T + dlv @ p["lvW"].T) * (1-h**2)
g["eW"], g["eb"] = x.T @ dh, dh.sum(0)
return result, g
def gradient_check(p, x):
"""Finite differences with fixed epsilon; check both beta paths, every tensor."""
rng = np.random.default_rng(7306)
eps = rng.normal(size=(len(x), 2))
checks = []
for beta in [0.0, 1.0]:
_, analytic = forward(p, x, eps, beta, True)
for name, tensor in p.items():
for flat in rng.choice(tensor.size, min(4, tensor.size), replace=False):
index = np.unravel_index(flat, tensor.shape)
original = tensor[index]
tensor[index] = original+1e-5
up = forward(p, x, eps, beta)["objective"]
tensor[index] = original-1e-5
down = forward(p, x, eps, beta)["objective"]
tensor[index] = original
numerical = (up-down)/2e-5
actual = analytic[name][index]
scaled = abs(numerical-actual)/max(1, abs(numerical), abs(actual))
checks.append({"beta": beta, "parameter": name, "index": list(index),
"analytic": float(actual), "finite_difference": float(numerical),
"scaled_error": float(scaled)})
assert max(c["scaled_error"] for c in checks) < 1e-6
return checks
def evaluate(p, x, eps_samples, beta):
records = [forward(p, x, eps, beta) for eps in eps_samples]
return {key: float(np.mean([v[key] for v in records])) for key in records[0]}
def train(initial, xtrain, xtest, test_eps, beta, cfg):
p = copy.deepcopy(initial)
m, v = ({k: np.zeros_like(a) for k, a in p.items()} for _ in range(2))
rng = np.random.default_rng(cfg["train_seed"])
log = [{"epoch": 0, **evaluate(p, xtest, test_eps, beta)}]
tick, step = time.perf_counter(), 0
for epoch in range(1, cfg["epochs"]+1):
permutation = rng.permutation(len(xtrain))
for start in range(0, len(xtrain), cfg["batch_size"]):
batch = xtrain[permutation[start:start+cfg["batch_size"]]]
eps = rng.normal(size=(len(batch), cfg["latent_dim"]))
_, grad = forward(p, batch, eps, beta, True)
step += 1
for k in p:
m[k] = .9*m[k] + .1*grad[k]
v[k] = .999*v[k] + .001*grad[k]**2
p[k] -= cfg["learning_rate"] * (m[k]/(1-.9**step))/(np.sqrt(v[k]/(1-.999**step))+1e-8)
if epoch % 5 == 0 or epoch == cfg["epochs"]:
log.append({"epoch": epoch, **evaluate(p, xtest, test_eps, beta)})
return p, log, time.perf_counter()-tick, step
def image_rows(rows, labels, target, titles=None, title="", scale=1):
nrows, ncols = len(rows), len(rows[0])
fig, axes = plt.subplots(nrows, ncols, figsize=(ncols*1.15*scale, nrows*1.25*scale),
squeeze=False)
for i, row in enumerate(rows):
for j, value in enumerate(row):
ax = axes[i,j]
ax.imshow(value.reshape(8,8), cmap="gray", vmin=0, vmax=1, interpolation="nearest")
ax.set_xticks([]); ax.set_yticks([])
if j == 0:
ax.set_ylabel(labels[i], fontsize=9)
if i == 0 and titles:
ax.set_title(titles[j], fontsize=9)
fig.suptitle(title, fontsize=12)
fig.tight_layout()
fig.savefig(target, dpi=160, bbox_inches="tight")
plt.close(fig)
def json_dump(path, value):
path.write_text(json.dumps(value, ensure_ascii=False, indent=2, default=lambda x: int(x) if isinstance(x,np.integer) else str(x)))
@threadpool_limits.wrap(limits=1, user_api='blas')
def run(out, epochs=None):
out = Path(out); out.mkdir(parents=True, exist_ok=True)
cfg = copy.deepcopy(CONFIG)
if epochs is not None: cfg["epochs"] = epochs
d = load_digits()
# Fixed binary observations make the product Bernoulli likelihood explicit.
x = (d.data > 8).astype(np.float64)
train_id, test_id = train_test_split(np.arange(len(x)), test_size=.2,
stratify=d.target, random_state=cfg["split_seed"])
xtrain, xtest = x[train_id], x[test_id]
cfg.update(train_size=len(train_id), test_size=len(test_id), parameters=0)
initial = initialize(cfg["init_seed"])
cfg["parameters"] = sum(a.size for a in initial.values())
json_dump(out/"config.json", cfg)
checks = gradient_check(initial, xtrain[:5])
json_dump(out/"gradient_check.json", {"passed": True, "checks": checks,
"max_scaled_error": max(c["scaled_error"] for c in checks)})
np.savez_compressed(out/"data.npz", gray=d.data/16, binary=x, labels=d.target,
train_indices=train_id, test_indices=test_id)
(out/"DATASET_DESCRIPTION.txt").write_text(d.DESCR)
test_eps = np.random.default_rng(cfg["evaluation_seed"]).normal(
size=(cfg["evaluation_mc_samples"], len(xtest), cfg["latent_dim"]))
visual_rng = np.random.default_rng(cfg["visual_seed"])
fixed_prior = visual_rng.normal(size=(25,2))
fixed_uniform = visual_rng.uniform(size=(25,64))
selected = np.array([np.flatnonzero(d.target[test_id] == digit)[0] for digit in range(10)])
selected_x = xtest[selected]
post_eps = visual_rng.normal(size=(4,8,2))
np.savez_compressed(out/"fixed_inputs.npz", prior=fixed_prior, observation_uniform=fixed_uniform,
selected_global_indices=test_id[selected], posterior_epsilon=post_eps, test_epsilon=test_eps)
np.savez_compressed(out/"weights_initial.npz", **initial)
models, histories, summaries = {}, {}, {}
for beta in cfg["betas"]:
tag = f"beta{beta:g}"
p, log, seconds, steps = train(initial, xtrain, xtest, test_eps, beta, cfg)
models[tag], histories[tag] = p, log
np.savez_compressed(out/f"weights_{tag}.npz", **p)
mu, lv = encode(p, xtest)
summaries[tag] = {"beta": beta, "elapsed_training_seconds": seconds, "updates": steps,
"initial_test": log[0], "final_test": log[-1],
"posterior_mean_radius_mean": float(np.linalg.norm(mu,axis=1).mean()),
"posterior_std_mean": float(np.exp(lv/2).mean())}
print(json.dumps(summaries[tag]), flush=True)
json_dump(out/"summary.json", {"config":cfg,"runs":summaries,
"environment":{"python":platform.python_version(),"numpy":np.__version__,
"scipy":scipy.__version__,"sklearn":sklearn.__version__,"matplotlib":matplotlib.__version__},
"gradient_check_max_scaled_error":max(c["scaled_error"] for c in checks)})
with (out/"learning_curves.csv").open("w", newline="") as f:
writer = csv.DictWriter(f,fieldnames=["beta","epoch","objective","reconstruction_nll","kl","negative_elbo"])
writer.writeheader()
for tag, log in histories.items():
for row in log: writer.writerow({"beta": summaries[tag]["beta"],**row})
# Fixed test input; using z=mu is deterministic reconstruction, not E_q[decoder(z)].
recon_rows = [d.data[test_id[selected]]/16, selected_x, decode(initial, encode(initial,selected_x)[0])]
for p in models.values(): recon_rows.append(decode(p, encode(p,selected_x)[0]))
model_labels = [f"beta={b:g}" for b in cfg["betas"]]
image_rows(recon_rows,["original\ngray","binary\ninput","initial\ndecoder(mu)"]+[label+"\ndecoder(mu)" for label in model_labels],
out/"reconstruction.png",[str(i) for i in range(10)],"Same held-out digits, before and after learning")
# Exactly the same z vectors in all rows; second figure uses same observation uniforms.
prior_rows = [decode(p,fixed_prior) for p in [initial,*models.values()]]
image_rows([r[:10] for r in prior_rows],["initial",*model_labels],out/"prior_same_seed.png",
title="Same ten z ~ N(0,I): decoder Bernoulli probabilities")
observation_tag = "beta1" if "beta1" in models else list(models)[-1]
p = models[observation_tag]
mean = decode(p,fixed_prior)
image_rows([mean[:10],(fixed_uniform<mean).astype(float)[:10]],
["probability\n(mean)","Bernoulli\nobservation"],out/"mean_vs_observation.png",
title="Same z, two different objects: probabilities and binary draws")
for tag,p in models.items():
means, logs = encode(p,xtest)
mu, lv = encode(p,selected_x[[0,3,6,9]])
zs = mu[:,None,:]+np.exp(lv[:,None,:]/2)*post_eps
post = decode(p,zs.reshape(-1,2)).reshape(4,8,64)
image_rows([np.concatenate([selected_x[[0,3,6,9]][i:i+1],post[i]],axis=0) for i in range(4)],
["digit 0","digit 3","digit 6","digit 9"],out/f"posterior_{tag}.png",
["input"]+[f"q draw {j+1}" for j in range(8)],title=f"{tag}: one input -> different posterior z -> mean images")
ends = encode(p,selected_x[[3,8]])[0]
alpha = np.linspace(0,1,11)
interp = (1-alpha[:,None])*ends[0]+alpha[:,None]*ends[1]
image_rows([decode(p,interp)],["linear z\npath"],out/f"interpolation_{tag}.png",
[f"{a:.1f}" for a in alpha],title=f"{tag}: interpolation from digit 3 to digit 8 (not prior draws)")
grid_axis = np.linspace(-3,3,15)
gx,gy = np.meshgrid(grid_axis,grid_axis[::-1])
grid = decode(p,np.column_stack([gx.ravel(),gy.ravel()]))
mosaic = grid.reshape(15,15,8,8).transpose(0,2,1,3).reshape(120,120)
fig,axs=plt.subplots(1,2,figsize=(11,5),constrained_layout=True)
axs[0].scatter(means[:,0],means[:,1],c=d.target[test_id],cmap="tab10",s=13,alpha=.65)
angle=np.linspace(0,2*np.pi,200)
axs[0].plot(2*np.cos(angle),2*np.sin(angle),color="black",ls="--",lw=1,label="radius 2 reference")
axs[0].scatter(fixed_prior[:,0],fixed_prior[:,1],marker="x",s=35,c="black",label="25 fixed prior draws")
axs[0].set(xlabel="z1",ylabel="z2")
axs[0].set_title(f"{tag}: held-out posterior means",fontsize=11,pad=10)
axs[0].legend(fontsize=7);axs[0].set_aspect("equal",adjustable="box")
axs[1].imshow(mosaic,cmap="gray",vmin=0,vmax=1,extent=(-3,3,-3,3),interpolation="nearest")
axs[1].set(xlabel="z1",ylabel="z2")
axs[1].set_title("Uniform grid in [-3,3]^2 (not prior samples)",fontsize=11,pad=10)
fig.savefig(out/f"latent_{tag}.png",dpi=160,bbox_inches="tight");plt.close(fig)
np.savez_compressed(out/f"observations_{tag}.npz",test_mu=means,test_logvar=logs,
selected_reconstruction=decode(p,encode(p,selected_x)[0]),prior_mean=decode(p,fixed_prior),
prior_observation=(fixed_uniform<decode(p,fixed_prior)).astype(np.uint8),
posterior_z=zs,posterior_mean_images=post,interpolation_z=interp,
interpolation_mean=decode(p,interp),grid_z=np.column_stack([gx.ravel(),gy.ravel()]),grid_mean=grid)
fig,axes=plt.subplots(1,2,figsize=(10,3.8))
for tag,log in histories.items():
axes[0].plot([v["epoch"] for v in log],[v["reconstruction_nll"] for v in log],label=tag)
axes[1].plot([v["epoch"] for v in log],[v["kl"] for v in log],label=tag)
axes[0].set(title="Held-out expected reconstruction NLL",xlabel="epoch",ylabel="nats/image; sum 64 pixels")
axes[1].set(title="Held-out KL(q(z|x) || N(0,I))",xlabel="epoch",ylabel="nats/image; sum 2 dimensions")
for ax in axes: ax.legend();ax.grid(alpha=.2)
fig.tight_layout();fig.savefig(out/"learning_curves.png",dpi=170);plt.close(fig)
manifest=[]
for path in sorted(out.iterdir()):
if path.is_file() and path.name!="manifest.json":
manifest.append({"file":path.name,"bytes":path.stat().st_size,"sha256":hashlib.sha256(path.read_bytes()).hexdigest()})
json_dump(out/"manifest.json",manifest)
return summaries
查看可执行代码
# 若重跑,请用新目录保留本册原始结果。
# 单变量对照任选一个:CONFIG["betas"] = [0.0, 0.5, 1.0]
# 或 run("lab07_shorter", epochs=50),不要同时改变两个设置。
published_repeat = run("lab07_rerun")
# 这些是本次重算目录里的新图,和上方保存的基线分别读取。
from IPython.display import display, Image
for figure in ["reconstruction.png", "prior_same_seed.png", "latent_beta0.png", "latent_beta1.png"]:
display(Image(filename="lab07_rerun/" + figure))
查看保存的计算输出
{"beta": 0.0, "elapsed_training_seconds": 1.5911131510001724, "updates": 3450, "initial_test": {"epoch": 0, "objective": 44.874893795622526, "reconstruction_nll": 44.874893795622526, "kl": 0.45410676032218933, "negative_elbo": 45.32900055594472}, "final_test": {"epoch": 150, "objective": 16.06052023596692, "reconstruction_nll": 16.06052023596692, "kl": 103.37481381849595, "negative_elbo": 119.43533405446287}, "posterior_mean_radius_mean": 12.323355857715377, "posterior_std_mean": 0.009828530272238265}
{"beta": 1.0, "elapsed_training_seconds": 1.5743949399984558, "updates": 3450, "initial_test": {"epoch": 0, "objective": 45.32900055594472, "reconstruction_nll": 44.874893795622526, "kl": 0.45410676032218933, "negative_elbo": 45.32900055594472}, "final_test": {"epoch": 150, "objective": 20.89589535072323, "reconstruction_nll": 18.637906550351587, "kl": 2.2579888003716375, "negative_elbo": 20.89589535072323}, "posterior_mean_radius_mean": 1.2849481784600931, "posterior_std_mean": 0.3496167331762039}
运行后请在 lab07_rerun/ 打开 reconstruction.png、prior_same_seed.png 和 latent_beta0.png/latent_beta1.png,再核对 summary.json。本册附带的保存图始终对应最初的 150 epoch、β=0/1 基线;重跑不会把新结果冒充原图。