wikiAudio01|WaveNet:原理、架构与实现附录|WaveNet 代码架构、运行入口与完整源码
26 分钟
0 次观看

附录|WaveNet 代码架构、运行入口与完整源码

这份脚本做一件具体的事:用四条可控谐波检查条件 WaveNet 的数据、训练和生成链路。读本页先看架构图与接口表,就能知道代码的职责和入口;完整脚本放在最后供复制,不需要先逐行读完。

返回 WaveNet 导读

1. 代码模块与调用关系

教学 WaveNet 总架构:12层残差主干、每层条件注入、各层 skip 求和与四级输出头

图 1|代码模块与计算架构

先沿中间主干看 WaveNet 包含什么:一个 Embedding、12 个 Block、一个 head。再进入 Block:它拥有 CausalConv、condition、residual 和 skip 四个子模块。最后沿橙色边检查每一份 skip 如何进入求和与输出头。绿色支路把同一时间轴的条件送入每个块;每块的条件投影参数独立。main 的实验组织职责与 step 的逐点接口见下表,下一张图再沿时间展开生成过程。

图中的 [4,24,512] 依次是批大小、隐藏通道、时间长度。类别数是 256,条件通道数是 2。方框里的数字都与下方默认代码一致;“24 维 embedding”和“256 类输出”承担不同角色。

对象输入 → 输出读代码时要抓住的约定
mu_encode / mu_decode浮点振幅 ↔ long 类别改数值表示,不改时间点数;解码有量化误差
CausalConv.forward[B,C,T] → [B,2C,T]只左补零,核位置顺序为旧→当前
Block.forward隐藏 xx 与目标条件 cc → 残差 rr、skip ss两个返回值分开走;chunk 沿通道轴
WaveNet.forward[B,T] 和 [B,H,T] → [B,256,T]所有 skip 相加后进入输出头
initial_state批大小 BB → 每层一个 [B,C,d] 队列每条新音频重置,零是隐藏向量
WaveNet.step[B]、[B,H]、state → [B,256]、新 state一次处理一个历史采样点,预测下一点
generateseed [B]、条件 [B,H,N] → 类别 [B,N]NN 是新生成点数;输出不含给定 seed
verify模型 → 结构检查结果同一固定输入比较两条计算路径
main命令行参数 → metrics.json、toy_weights.pt准备数据、检查、训练、再次检查、生成
接口中的维度说明
数学记号含义、单位与示例
BB批大小。
CC隐藏通道数。
TT输入网络的时间长度。
HH条件通道数。
dd某一层的膨胀率。
NN生成接口中要追加的新采样点数。

这些量都是计数,不以秒为单位;时间长度需要用点数除以采样率。

2. 训练与生成的执行路径

训练与生成的展开图:真实输入的并行预测、抽样类别反馈和逐层缓存状态传递

图 2|训练与生成的执行路径

对照时间展开图,按下表追踪两条调用链:

阶段训练路径自由生成路径
准备main 构造 inp,target,aligned_condgenerate 初始化零 state 与 seed
主网络WaveNet.forward每轮调用 WaveNet.step
块内计算Block.forward → CausalConv.forwardBlock.step 读取并更新本层队列
输出处理logits 进入损失,优化器更新参数logits → softmax → 抽样
下次使用的历史数据中的真实历史刚抽到的类别与更新后的 state

以 [12,18,25,20] 为例,训练输入是 [12,18,25]、标签是 [18,25,20],一次 forward 计算三个位置。生成只有 seed 已知;若抽到 1717,下一步必须输入 1717。生成时参数不变,变化的是历史与缓存。

第一次读只需追踪这两条路线。verify 负责证明它们在固定输入上相符;参数解析、JSON 写出、计时等外围代码可在真正运行时再看。想深挖一个函数时,返回导读进入相应专题:编码看 01,因果卷积看 02,Block 看 03,损失看 04,step 看 05,测试证据看 06。

3. 运行环境、输入与产物

将最后一节代码保存为 wavenet_minimal.py。已验证环境为 Windows、Python 3.12.14、PyTorch 2.14.0+cpu。训练只依赖标准库和 PyTorch,不需要下载语音数据或使用 GPU。

python -m venv .venv
.\.venv\Scripts\python.exe -m pip install torch==2.14.0 --index-url https://download.pytorch.org/whl/cpu
.\.venv\Scripts\python.exe wavenet_minimal.py --steps 300 --out results

--steps 300 表示进行 300 次参数更新,记录中 step=0 是更新前损失;--out results 指定输出目录。已有兼容环境可直接执行最后一行。部分环境提示缺少 NumPy,但本脚本没有使用 NumPy 转换。

运行后先核对 metrics.json,再判断是否需要阅读具体实现:

产物或字段预期检查含义
参数总数、感受野5749657496、127127结构配置与教程一致
生成 shape[1,128]短序列生成检查完成
结构断言全部通过对应的数值与索引测试通过
loss已有实测约 5.5653→0.003935.5653\to0.00393小样本拟合结果;不要求跨机器逐位相同
toy_weights.pt仅对应四条人工信号不能用于朗读文本

脚本不输出 WAV,不包含文本前端、说话人编码器或真实语音训练集。

4. 实现约定与常见错误

看到的实现原因 / 边界
类别 embedding、24 通道、12 块为可观察的小实验选定;不是原论文完整训练配置
残差除以 √2,skip 总和除以 √LL本实现的尺度控制;LL 是块数 12,不是序列长度
前 RR−1 个输出不直接计分RR 是感受野 127;让计分处具备完整真实历史
条件 [F0/250,A]F0F_0 是 Hz,250 是归一化参照频率;AA 是振幅系数
state 使用 torch.cat易读、便于核对索引,会复制队列;没有实现生产级环形缓冲
float32 / float64 都验证区分浮点累加误差与缓存索引错误

下一节是完整可运行参考。理解结构后,只需按表定位关心的类;复制运行时则保留整个文件,不能只复制某个依赖其他类的片段。

5. 完整实现源码

代码保持与已执行实验一致。以下是一个文件,不需要把多个零散片段自行拼接。

python219 行
"""Educational conditional WaveNet; synthetic plumbing test, not a speech model."""
import argparse
import copy
import json
import math
import platform
import time
from pathlib import Path

import torch
from torch import nn
from torch.nn import functional as F


def mu_encode(x, mu=255):
    x = x.clamp(-1, 1)
    y = x.sign() * torch.log1p(mu * x.abs()) / math.log1p(mu)
    return ((y + 1) * mu / 2).round().long()


def mu_decode(q, mu=255):
    y = 2 * q.float() / mu - 1
    return y.sign() * torch.expm1(y.abs() * math.log1p(mu)) / mu


class CausalConv(nn.Module):
    def __init__(self, channels, dilation):
        super().__init__()
        self.dilation = dilation
        self.conv = nn.Conv1d(channels, 2 * channels, 2, dilation=dilation)

    def forward(self, x):
        return self.conv(F.pad(x, (self.dilation, 0)))

    def step(self, x, history):
        # x: [B,C], history: [B,C,d]; weights ordered old -> current.
        w = self.conv.weight
        y = F.linear(history[:, :, 0], w[:, :, 0])
        y = y + F.linear(x, w[:, :, 1], self.conv.bias)
        history = torch.cat((history[:, :, 1:], x.unsqueeze(-1)), dim=-1)
        return y, history


class Block(nn.Module):
    def __init__(self, channels, cond_channels, dilation):
        super().__init__()
        self.causal = CausalConv(channels, dilation)
        self.condition = nn.Conv1d(cond_channels, 2 * channels, 1)
        self.residual = nn.Conv1d(channels, channels, 1)
        self.skip = nn.Conv1d(channels, channels, 1)

    def forward(self, x, cond):
        a, b = (self.causal(x) + self.condition(cond)).chunk(2, dim=1)
        z = torch.tanh(a) * torch.sigmoid(b)
        return (x + self.residual(z)) / math.sqrt(2), self.skip(z)

    def step(self, x, cond, history):
        a, history = self.causal.step(x, history)
        a = a + self.condition(cond.unsqueeze(-1)).squeeze(-1)
        f, g = a.chunk(2, dim=1)
        z = (f.tanh() * g.sigmoid()).unsqueeze(-1)
        residual = (x + self.residual(z).squeeze(-1)) / math.sqrt(2)
        return residual, self.skip(z).squeeze(-1), history


class WaveNet(nn.Module):
    def __init__(self, channels=24, cond_channels=2,
                 dilations=(1, 2, 4, 8, 16, 32, 1, 2, 4, 8, 16, 32)):
        super().__init__()
        self.channels = channels
        self.dilations = tuple(dilations)
        self.receptive_field = 1 + sum(dilations)
        self.embedding = nn.Embedding(256, channels)
        self.blocks = nn.ModuleList([
            Block(channels, cond_channels, d) for d in dilations
        ])
        self.head = nn.Sequential(nn.ReLU(), nn.Conv1d(channels, channels, 1),
                                  nn.ReLU(), nn.Conv1d(channels, 256, 1))

    def forward(self, q_input, cond_target):
        # q_input[:,n] predicts q[:,n+1]; cond_target is aligned to q[:,n+1].
        x = self.embedding(q_input).transpose(1, 2)
        skips = []
        for block in self.blocks:
            x, skip = block(x, cond_target)
            skips.append(skip)
        return self.head(torch.stack(skips).sum(0) / math.sqrt(len(skips)))

    def initial_state(self, batch_size):
        p = next(self.parameters())
        return [p.new_zeros(batch_size, self.channels, d) for d in self.dilations]

    def step(self, q_previous, cond_target, state):
        x = self.embedding(q_previous)
        skips, next_state = [], []
        for block, history in zip(self.blocks, state):
            x, skip, history = block.step(x, cond_target, history)
            skips.append(skip)
            next_state.append(history)
        s = torch.stack(skips).sum(0) / math.sqrt(len(skips))
        return self.head(s.unsqueeze(-1)).squeeze(-1), next_state


@torch.no_grad()
def generate(model, seed, cond_future, temperature=1.0):
    """One known seed sample; cond_future [B,H,N] aligns to N new samples."""
    if temperature <= 0:
        raise ValueError('temperature must be positive')
    model.eval()
    state = model.initial_state(seed.shape[0])
    previous, samples = seed, []
    for i in range(cond_future.shape[-1]):
        logits, state = model.step(previous, cond_future[:, :, i], state)
        probs = torch.softmax(logits / temperature, dim=1)
        previous = torch.multinomial(probs, 1).squeeze(1)
        samples.append(previous)
    return torch.stack(samples, dim=1)


@torch.no_grad()
def verify(model):
    model.eval()
    q = torch.randint(0, 256, (2, 80))
    cond = torch.randn(2, 2, 80)
    y = model(q, cond)
    q2, c2 = q.clone(), cond.clone()
    q2[:, 41:] = torch.randint(0, 256, q2[:, 41:].shape)
    c2[:, :, 41:] = torch.randn_like(c2[:, :, 41:])
    causal_error = (y[:, :, :41] - model(q2, c2)[:, :, :41]).abs().max().item()
    state = model.initial_state(2)
    steps = []
    for i in range(q.shape[1]):
        z, state = model.step(q[:, i], cond[:, :, i], state)
        steps.append(z)
    cache_error = (y - torch.stack(steps, dim=2)).abs().max().item()
    bins = torch.arange(256)
    assert torch.equal(mu_encode(mu_decode(bins)), bins)
    assert y.shape == (2, 256, 80)
    assert causal_error < 1e-6
    torch.testing.assert_close(y, torch.stack(steps, dim=2), atol=1e-4, rtol=1e-5)
    # Conv1d and Linear may use different float32 accumulation orders.
    # Double precision independently checks the cache indexing after training.
    precise = copy.deepcopy(model).double()
    full64 = precise(q, cond.double())
    state64, steps64 = precise.initial_state(2), []
    for i in range(q.shape[1]):
        z, state64 = precise.step(q[:, i], cond[:, :, i].double(), state64)
        steps64.append(z)
    cache64_error = (full64 - torch.stack(steps64, dim=2)).abs().max().item()
    assert cache64_error < 1e-10
    # Finite receptive field: earliest input cannot affect final position if T>R.
    long_q = torch.randint(0, 256, (1, model.receptive_field + 8))
    long_c = torch.randn(1, 2, long_q.shape[1])
    before = model(long_q, long_c)[:, :, -1]
    long_q[:, 0] = (long_q[:, 0] + 100) % 256
    rf_error = (before - model(long_q, long_c)[:, :, -1]).abs().max().item()
    assert rf_error < 1e-6
    return dict(causal_max_error=causal_error, cache_max_error=cache_error,
                cache_float64_max_error=cache64_error,
                outside_receptive_field_error=rf_error, quantizer_roundtrip=True,
                output_shape=list(y.shape))


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('--steps', type=int, default=300)
    parser.add_argument('--out', type=Path, default=Path('results'))
    args = parser.parse_args()
    args.out.mkdir(parents=True, exist_ok=True)
    torch.set_num_threads(2)
    torch.manual_seed(7)
    model = WaveNet()
    checks = verify(model)
    # Fixed 4-example batch: deliberately an overfit test, no validation set.
    fs = 8000
    t = torch.arange(513) / fs
    f0 = torch.tensor([125., 125., 250., 250.])[:, None]
    phase = torch.tensor([0., 0.7, 0., 0.7])[:, None]
    amp = torch.tensor([0.55, 0.75, 0.55, 0.75])[:, None]
    wave = amp * (torch.sin(2 * math.pi * f0 * t + phase)
                  + .2 * torch.sin(4 * math.pi * f0 * t + 2 * phase)) / 1.2
    q = mu_encode(wave)
    cond = torch.stack(((f0 / 250).expand(-1, 513), amp.expand(-1, 513)), dim=1)
    inp, target, aligned_cond = q[:, :-1], q[:, 1:], cond[:, :, 1:]
    warmup = model.receptive_field - 1
    opt = torch.optim.Adam(model.parameters(), lr=2e-3)
    losses = []
    start = time.perf_counter()
    for step in range(args.steps + 1):
        model.train()
        logits = model(inp, aligned_cond)
        loss = F.cross_entropy(logits[:, :, warmup:], target[:, warmup:])
        if step % 10 == 0 or step == args.steps:
            losses.append({'step': step, 'loss': loss.item()})
        if step == args.steps:
            break
        opt.zero_grad(set_to_none=True)
        loss.backward()
        nn.utils.clip_grad_norm_(model.parameters(), 1.0)
        opt.step()
    elapsed = time.perf_counter() - start
    assert losses[-1]['loss'] < losses[0]['loss'] * .7, 'overfit smoke test failed'
    post_checks = verify(model)
    samples = generate(model, q[:1, 0], cond[:1, :, 1:129])
    result = dict(python=platform.python_version(), torch=torch.__version__,
                  device='cpu', seed=7, threads=2, steps=args.steps,
                  parameters=sum(p.numel() for p in model.parameters()),
                  receptive_field=model.receptive_field, sample_rate=fs,
                  elapsed_seconds=elapsed, losses=losses,
                  checks_before=checks, checks_after=post_checks,
                  generated_shape=list(samples.shape),
                  warning='Fixed synthetic training batch; no speech quality/generalization claim.')
    (args.out / 'metrics.json').write_text(json.dumps(result, indent=2), encoding='utf-8')
    torch.save(model.state_dict(), args.out / 'toy_weights.pt')
    print(json.dumps(result, indent=2))


if __name__ == '__main__':
    main()

正在连接 GitHub 评论…