这份文档对应当前目录下的 03_semantic_loss_toy 项目,目标不是只告诉你“它能跑”,而是把它拆成“你能自己改、自己讲”的程度。
说明:
README.md 和 notes.md。这个 toy 的执行路径是:
run.py
ExperimentConfigexperiment.py 里的 run_experimentexperiment.py
metrics.json 和图trainer.py
constraints.py
data.py
model.py
所以这整个项目压成一句话就是:
先造一个 4 类分类问题,再把“合法输出结构”写成一组二值赋值集合,然后让模型在无标签样本上尽量把概率质量压到这些合法赋值上。
这个文件是命令行入口。
1: import argparse
2: from pathlib import Path
4: from config import ExperimentConfig
5: from constraints import available_constraint_sets
choices= 只能取合法约束名。6: from experiment import run_experiment
parse_args9: def parse_args():
10: ArgumentParser(description="Semantic loss toy experiment")
11: --seed
12: --epochs
13: --num-labeled
14: --num-unlabeled
15: --lambda-semantic
16-21
--constraint-set 参数。default="exactly_one" 表示默认用正确约束。choices=available_constraint_sets() 强制用户只能在预设集合里选。22: --experiment-name
23: --skip-plots
24: return parser.parse_args()
build_config27: def build_config(args) -> ExperimentConfig:
28-37
ExperimentConfig(...)。config.py 里的默认值。36
results_dir=Path(__file__).resolve().parent / "results" / args.experiment_nameresults/<实验名>/。38
39
main42: def main():
43
44
45
run_experiment(...) 执行整个实验。save_artifacts=True 表示保存 metrics.json。save_plots=not args.skip_plots 表示默认会画图,除非显式跳过。47-55
58-59
main()。这个文件的核心作用很纯粹:
它不管训练细节,只负责把“命令行世界”转成“实验配置世界”。
这个文件集中定义所有实验超参数。
1: from dataclasses import dataclass
dataclass 简化配置类的书写。2: from pathlib import Path
3: from typing import Optional
results_dir 可以先为空。6: @dataclass
7: class ExperimentConfig:
8: seed: int = 17
9: num_classes: int = 4
10: num_labeled: int = 48
11: num_unlabeled: int = 768
12: num_val: int = 256
13: num_test: int = 512
14: hidden_dim: int = 48
15: batch_size_labeled: int = 24
16: batch_size_unlabeled: int = 96
17: epochs: int = 160
18: learning_rate: float = 1e-2
19: weight_decay: float = 1e-4
20: lambda_semantic: float = 0.8
21: ramp_up_epochs: int = 40
22: constraint_set_name: str = "exactly_one"
23: constraint_threshold: float = 0.5
sigmoid(logit) >= 0.5 看成该输出位取 1。24: mesh_step: float = 0.05
25: device: str = "cpu"
26: experiment_name: str = "default"
27: results_root: Path = Path(__file__).resolve().parent / "results"
results/。28: results_dir: Optional[Path] = None
ensure_results_dir30: def ensure_results_dir(self) -> Path:
31-32
results_dir,就自动拼成 results_root / experiment_name。33
parents=True 允许递归创建多级目录。exist_ok=True 表示目录已存在也不报错。34
to_dict36: def to_dict(self) -> dict:
metrics.json。37-58
57
results_dir 这里转成字符串,因为 JSON 不能直接序列化 Path。这个文件的核心思想是:
所有模块都只接收一个
config,不各自偷偷维护一套参数。
这个文件负责定义 toy 数据分布。
1: 导入 dataclass3: 导入 torch4: 导入 TensorDataset
7: @dataclass8: class DatasetBundle:
9: labeled
10: unlabeled
11: val
12: test
15-16
set_seed(seed) 只做一件事:调用 torch.manual_seed(seed)。class_scores19: def class_scores(x: torch.Tensor) -> torch.Tensor:
20: x1 = x[:, 0]
21: x2 = x[:, 1]
22-25
s0, s1, s2, s3。26
[batch, 4] 的张量。这几行的本质是:
先人为定义 4 个“类势能面”,哪个分数最大,样本就属于哪个类。
make_labels29-30
class_scores(x).argmax(dim=1)。_sample_candidate_pool33: 内部辅助函数。34
[-2, 2] x [-2, 2] 上均匀采样点。35
36
make_balanced_dataset这个函数很关键,它不是随便采样,而是主动让每一类样本数大致平衡。
39-43
num_pointsnum_classesgenerator44
num_points // num_classes 个样本。45-46
48-50
xs、标签缓存 ys,以及每类当前已有数量 current_counts。52
53
max(6 * num_points, 512) 的意思是:候选池不要太小,不然类别不够平衡时会频繁重采。55
56
57-58
60
class_mask = y_pool == class_idx 找出当前候选池里属于这个类别的样本。61
62
remaining 和 available 里的较小值。63-64
66-68
70-71
72
73
这整个函数的目的就是:
尽量避免“某一类太少”,否则很容易把后面 accuracy 和 semantic effect 的比较搅乱。
create_datasets76: def create_datasets(config) -> DatasetBundle:
77
torch.Generator。79-82
84-89
TensorDataset 包装后打包成 DatasetBundle 返回。这个文件的核心思想是:
故意制造“少量标注 + 大量无标签 + 类别平衡”的训练环境,让你能清楚观察 semantic loss 的作用。
这是整个 toy 最关键的文件,因为 semantic loss 真正定义在这里。
1: 导入 dataclass2: 导入 itertools
4: 导入 torch5: 导入 torch.nn.functional as FConstraintSpec8: @dataclass(frozen=True)
frozen=True 表示约束对象创建后不应再被修改。9: class ConstraintSpec:
10: name
11: description
12: num_classes
13: valid_assignments
_all_binary_assignments16: def _all_binary_assignments(num_classes: int) -> torch.Tensor:
17
itertools.product([0.0, 1.0], repeat=num_classes) 会生成全部 2^num_classes 个二值组合。num_classes=4,所以一共是 16 个。18
torch.Tensor。available_constraint_sets21-22
exactly_oneat_least_oneexactly_two_badget_constraint_spec25
26
27
exactly_one29-31
1000010000100001at_least_one32-34
exactly_one 弱很多。1100、1111 这样的多激活结构也会被视为合法。exactly_two_bad35-37
38-39
ConstraintSpec41-46
log_satisfaction_mass这是 semantic loss 的数学核心。
49: def log_satisfaction_mass(logits: torch.Tensor, spec: ConstraintSpec) -> torch.Tensor:
50
logits 同样的设备、同样的数据类型上。51
F.logsigmoid(logits) 等价于 log(sigmoid(logits))。.unsqueeze(1) 把形状从 [B, C] 变成 [B, 1, C],为了后面和所有合法赋值做广播。52
F.logsigmoid(-logits) 等价于 log(1 - sigmoid(logits))。[B, 1, C]。53
assignments.unsqueeze(0) 形状是 [1, K, C],这里 K 是合法赋值个数。log_prob_one。log_prob_zero。log_terms 的形状是 [B, K, C]。54
log_terms.sum(dim=2) 先把每个合法赋值在各个输出位上的 log 概率加起来,得到 [B, K]。torch.logsumexp(..., dim=1),把所有合法赋值的概率质量加起来,并且保持数值稳定。[B]。这一段对应的直觉公式就是:
log P(约束成立) = log Σ_{a 属于合法赋值集合} P(a)
而单个赋值 a 的概率由各个输出位独立 sigmoid 给出。
satisfaction_probability57-58
log_satisfaction_mass 取指数。semantic_loss61-62
- mean(log P(约束成立))
hard_constraint_satisfaction这部分不参与训练,只用于评估“硬满足率”。
65-69
70
71
72
all(dim=2) 表示每一位都要一样。73
serialize_constraint_spec76-83
ConstraintSpec 变成普通字典,方便写进 metrics.json。valid_assignments 也一起保存下来,这对你后面复盘非常有用。这个文件的真正核心思想是:
语义知识不是一句口号,而是一个合法赋值集合;semantic loss 就是在问模型当前到底给这个集合分了多少概率质量。
这个文件很短,但它的设计选择很重要。
1: import torch.nn as nn
TinySemanticMLP4: class TinySemanticMLP(nn.Module):
5
6
7-13
nn.Sequential 堆一个两层隐藏层网络:8: 输入 2 维 -> 隐藏层9: Tanh10: 隐藏层 -> 隐藏层11: Tanh12: 隐藏层 -> num_classes 个输出 logits15-16
self.net。这里最重要的不是“网络有多复杂”,而是:
输出层给的是 4 个独立 logits,而不是
softmax概率。
这件事非常关键,因为 semantic loss 要做的是对所有二值赋值建模。
如果直接用 softmax,one-hot 结构会被模型结构本身部分硬编码掉,演示效果就没那么干净。
这是训练、评估、画图的主战场。
1-5
copy、itertools、json、math、Path。7-12
matplotlib、numpy、torch、F、ListedColormap、DataLoader。14
constraints.py 导入三个核心函数:hard_constraint_satisfactionsatisfaction_probabilitysemantic_loss15
17
matplotlib.use("Agg")18
matplotlib.pyplot。ManualAdamW这部分是手写的 AdamW 优化器。
21: class ManualAdamW:
22
23
requires_grad=True 的参数。24-28
29-35
exp_avg 是一阶动量exp_avg_sq 是二阶动量zero_grad37-40
step42
43-45
47
torch.no_grad() 里更新参数,避免把更新过程也记进计算图。48-50
52-55
57
58
60-62
64-65
67
对你理解这个 toy 来说,这段不是 semantic loss 的重点,但它承担了一个工程角色:
让这个项目不依赖外部优化器实现细节,自己就能完成稳定训练。
_to_device70-71
_one_hot_targets74-75
BCEWithLogits 一起用。semantic_weight_at78-80
epoch / ramp_up_epochs 线性升高,再乘 lambda_semantic。这意味着:
evaluate这个函数负责统一评估。
83
84
eval() 模式。85-87
TensorDataset 中取出整份数据并放到设备上。89
90
91
sigmoid,得到独立激活概率。92
argmax 取预测类别。93
95
BCEWithLogits 计算监督损失。softmax + cross entropy 训练的。96
97-101
103-110
lossaccuracymean_confidencemean_satisfaction_probabilityhard_constraint_satisfaction_ratehard_constraint_violation_rate这里有一个很重要的理解点:
在这个 toy 里,accuracy 和 constraint satisfaction 是两条不同的轴。
也就是说:
_make_optimizer113-118
ManualAdamW。train_baseline这是纯监督版本。
121
122
123
124
DataLoader。126-128
history 里记录训练损失、验证准确率和验证满足概率。130
131
132
134
135
136
137
138
BCEWithLogits(logits, one_hot_targets)。139
140
141
143
144-146
history。148-150
152-153
这个 baseline 很重要,因为它让你能回答一个干净的问题:
不加语义知识时,单靠少量标注,模型能学到什么程度?
train_semantic_guided这是整份 toy 里最重要的训练函数。
156
157
158
159
160
162-170
semantic_loss 曲线。172
173
174
lambda_t。175-176
itertools.cycle(...) 把两个 loader 都变成可循环迭代器。177
max(len(labeled_loader), len(unlabeled_loader)) 步。179-182
184
185
186
TensorDataset 里也有标签,但训练时故意不使用它。188
189
190
192-195
196
semantic_loss(unlabeled_logits, constraint_spec)。197
total_loss = supervised_term + lambda_t * semantic_term
logic_net_toy 不同,这里没有 teacher-student 蒸馏,也没有 KL distillation。它是更直接的“监督项 + 语义项”。
198
199
201-205
207
208-212
214-216
218-219
这整个函数最值得你记住的是:
semantic loss 并不是替代监督项,而是在无标签数据上补一条“输出结构应该像什么样”的训练信号。
save_metrics222-224
metrics.json。ensure_ascii=False 保证中文字段也能正常写。plot_training_curves227
228
230
231
232
233
234-236
238
239
240
241
242-245
247-249
plot_decision_and_constraint_maps这个函数把“分类区域”和“约束满足概率区域”画在一张图里。
252
253
254-255
257-260
grid 是所有网格点拼成的 [N, 2] 张量。262
2 x 2 子图。263
264
266
267-272
logitssigmoid 概率argmax 得到预测类别satisfaction_probability 得到每个网格点的约束满足概率274-275
277-284
contourf 画预测类别区域。levels=np.arange(config.num_classes + 1) - 0.5 用来让 4 个离散类别正好各占一个颜色区间。285
286-287
289
contourf 画满足概率热力图。290
291-292
294
296-297
298-299
这个函数很有价值,因为它同时把两件事画出来了:
模型“把哪里判成哪一类”和“模型在什么区域更满足约束”,并不总是同一件事。
这个文件负责把前面的模块串起来。
1: 导入 get_constraint_spec 和 serialize_constraint_spec2: 导入 create_datasets 和 set_seed3-10
trainer.py 导入训练、评估、绘图、保存指标这些主函数run_experiment13
14
15
16
17
19
20
22
23
25-37
metrics 字典。delta_accuracydelta_satisfaction_probabilitydelta_violation_rate这里要特别注意:
30
delta_accuracy = semantic - baseline31-33
delta_satisfaction_probability 也是 semantic 减 baseline。34-36
delta_violation_rate 仍然是 semantic 减 baseline。39
40
metrics.json。41
42-49
50-54
56
这个文件的作用可以概括成:
它不定义新算法,只做一件事:把配置、数据、约束、训练、评估、可视化组织成一个完整实验。
softmax + cross entropy?因为这里要演示的是“输出结构约束”。
如果直接用 softmax,模型天生就会把输出压成总和为 1 的分布,exactly-one 的一部分结构会被模型形式提前吸收掉。
这里故意写成:
这样你才能清楚看到 semantic loss 真正在补什么。
它不是算“哪个类最大”。 它算的是:
当前模型输出分布,在所有合法二值赋值上的总概率质量
如果这个总质量大,说明模型更相信合法结构。 如果这个总质量小,说明模型把概率分到了很多不合法结构上。
因为这正是它最有代表性的使用方式:
这能最清楚地体现:
就算没有标签,只要你知道输出该长什么样,也能给模型额外训练信息。
hard_constraint_satisfaction_rate?因为 mean_satisfaction_probability 是软指标。
它告诉你“平均来看合法概率多不多”。
但有时你还想知道:
真要把每一位硬阈值化,最终有多少输出真的落在合法集合里?
这就是硬满足率的作用。
logic_net_toy 最本质的区别是什么?logic_net_toy:
semantic_loss_toy:
也就是说,这个 toy 更接近“语义直接进 loss”的思路。
如果你是第一次看,建议顺序是:
run.py
experiment.py
constraints.py
trainer.py
data.py 和 model.py
如果你是准备改代码,最值得先动的地方是:
constraints.py
trainer.py
run.py
data.py
这份解析读完后,你应该至少能自己说清楚三句话:
log_satisfaction_mass 那一行广播到底在算什么。