yusiwen commited on
Commit
32fb2ef
·
unverified ·
1 Parent(s): 794da56

feat: add GCN, DQN, SimCLR, YOLO (4 new models + notebooks)

Browse files
README.md CHANGED
@@ -49,11 +49,36 @@ Implement mainstream deep learning models from scratch.
49
  │ └── generate.py # Sample generation + latent interpolation
50
  ├── ddpm/
51
  │ ├── __init__.py
52
- │ ├── config.yaml # DDPM hyperparameters (T=1000, noise schedule, etc.)
53
  │ ├── model.py # UNet + timestep embedding + DDPM forward/sample
54
  │ ├── data.py # CIFAR-10 via HF datasets
55
  │ ├── train.py # Noise prediction training
56
  │ └── generate.py # Reverse diffusion sampling
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  ├── dcgan/
58
  │ ├── __init__.py
59
  │ ├── config.yaml # DCGAN hyperparameters
@@ -204,6 +229,42 @@ uv run python -m resnet18.train
204
  | Training | Noise prediction (MSE), T=1000, linear β schedule |
205
  | Sampling | Reverse diffusion (x_T → x_0), 1000 steps |
206
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  ## DCGAN
208
 
209
  | Item | Value |
@@ -349,6 +410,10 @@ it demonstrates.
349
  | `nlp/bert/` | BERT mini | **Self-Attention** (semantic aggregation), **Masked Language Model** (entropy increase + denoising), LayerNorm, positional encoding |
350
  | `nlp/word2vec/` | Word2Vec | **Embedding lookup tables**, **Negative Sampling**, CBOW vs Skip-gram, subsampling frequent words, cosine similarity |
351
  | `nlp/lstm/` | LSTM | **Input/forget/output gates**, **cell state**, gradient flow through gating, sequential processing vs parallel attention |
 
 
 
 
352
  | `nlp/gpt/` | GPT | **Causal Self-Attention**, **KV Cache**, autoregressive generation, word-level tokenizer, temperature + top-k sampling, bad-token blocking |
353
 
354
  ## Setup & Run
@@ -378,6 +443,19 @@ uv run python -m vae.generate
378
  uv run python -m nlp.seq2seq.train
379
  uv run python -m nlp.seq2seq.generate
380
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
  # Train / Generate DDPM
382
  uv run python -m ddpm.train
383
  uv run python -m ddpm.generate
@@ -443,6 +521,10 @@ locally after training; paths are shown below for reference.
443
  | ResNet50 (40 attrs, 200K samples) | `resnet50/resnet50_celeba.pt` | ~90 MB |
444
  | VAE (CelebA, 64×64) | `vae/vae_celeba.pt` | 10 MB |
445
  | Seq2Seq Transformer (Multi30k) | `nlp/seq2seq/seq2seq_multi30k.pt` | 4 MB |
 
 
 
 
446
  | DDPM (CIFAR-10, 32×32) | `ddpm/ddpm_cifar10.pt` | 62 MB |
447
  | DCGAN (CelebA, 64×64) | `dcgan/dcgan_celeba.pt` | ~23 MB (G+D) |
448
  | ViT (CIFAR-10, 32×32) | `vit/vit_cifar10.pt` | 3.2 MB |
 
49
  │ └── generate.py # Sample generation + latent interpolation
50
  ├── ddpm/
51
  │ ├── __init__.py
52
+ │ ├── config.yaml # DDPM hyperparameters
53
  │ ├── model.py # UNet + timestep embedding + DDPM forward/sample
54
  │ ├── data.py # CIFAR-10 via HF datasets
55
  │ ├── train.py # Noise prediction training
56
  │ └── generate.py # Reverse diffusion sampling
57
+ ├── gcn/
58
+ │ ├── __init__.py
59
+ │ ├── config.yaml # GCN hyperparameters
60
+ │ ├── model.py # Graph Convolution layers + 2-layer GCN
61
+ │ ├── data.py # Cora citation network loader
62
+ │ ├── train.py # Semi-supervised node classification
63
+ │ └── eval.py # Test accuracy evaluation
64
+ ├── dqn/
65
+ │ ├── __init__.py
66
+ │ ├── config.yaml # DQN hyperparameters
67
+ │ ├── dqn.py # DQN, ReplayBuffer, train_episode helpers
68
+ │ └── train.py # CartPole RL training loop
69
+ ├── simclr/
70
+ │ ├── __init__.py
71
+ │ ├── config.yaml # SimCLR hyperparameters
72
+ │ ├── model.py # ResNet18 encoder + Projector + NT-Xent loss
73
+ │ ├── data.py # CIFAR-10 with dual augmentation
74
+ │ └── train.py # Contrastive learning training
75
+ ├── yolo/
76
+ │ ├── __init__.py
77
+ │ ├── config.yaml # YOLO hyperparameters
78
+ │ ├── model.py # CNN backbone + detection head
79
+ │ ├── loss.py # YOLO loss + NMS
80
+ │ ├── data.py # Pascal VOC dataset
81
+ │ └── train.py # Object detection training
82
  ├── dcgan/
83
  │ ├── __init__.py
84
  │ ├── config.yaml # DCGAN hyperparameters
 
229
  | Training | Noise prediction (MSE), T=1000, linear β schedule |
230
  | Sampling | Reverse diffusion (x_T → x_0), 1000 steps |
231
 
232
+ ## GCN
233
+
234
+ | Item | Value |
235
+ |---|---|
236
+ | Model | 2-layer Graph Convolutional Network (23K params) |
237
+ | Dataset | Cora via URL — 2708 nodes, 1433 features, 7 classes |
238
+ | Architecture | GraphConv × 2: Â @ H @ W (spectral graph convolution) |
239
+ | Training | Semi-supervised (20 labels/class), CrossEntropyLoss |
240
+
241
+ ## DQN
242
+
243
+ | Item | Value |
244
+ |---|---|
245
+ | Model | Deep Q-Network (17K params) |
246
+ | Environment | CartPole-v1 via Gymnasium — 4-dim state, 2 actions |
247
+ | Architecture | 3-layer MLP (4→128→128→2) |
248
+ | Training | Experience replay, target network, ε-greedy decay |
249
+
250
+ ## SimCLR
251
+
252
+ | Item | Value |
253
+ |---|---|
254
+ | Model | SimCLR (11M params: ResNet18 encoder + MLP projector) |
255
+ | Dataset | CIFAR-10 via HF datasets — self-supervised (no labels) |
256
+ | Architecture | ResNet18 → Projector(512→256→128) → NT-Xent loss |
257
+ | Training | 100 epoch, temperature=0.5, dual random augmentation |
258
+
259
+ ## YOLO
260
+
261
+ | Item | Value |
262
+ |---|---|
263
+ | Model | Simplified YOLO (59M params) |
264
+ | Dataset | Pascal VOC via HF datasets — 20 classes |
265
+ | Architecture | CNN backbone → FC detection head → 7×7×30 output |
266
+ | Training | YOLO loss (coord + obj + noobj + class), NMS at inference |
267
+
268
  ## DCGAN
269
 
270
  | Item | Value |
 
410
  | `nlp/bert/` | BERT mini | **Self-Attention** (semantic aggregation), **Masked Language Model** (entropy increase + denoising), LayerNorm, positional encoding |
411
  | `nlp/word2vec/` | Word2Vec | **Embedding lookup tables**, **Negative Sampling**, CBOW vs Skip-gram, subsampling frequent words, cosine similarity |
412
  | `nlp/lstm/` | LSTM | **Input/forget/output gates**, **cell state**, gradient flow through gating, sequential processing vs parallel attention |
413
+ | `gcn/` | GCN | Graph convolution, message passing, semi-supervised node classification |
414
+ | `dqn/` | DQN | Q-Learning, experience replay, target network, ε-greedy |
415
+ | `simclr/` | SimCLR | Contrastive learning, NT-Xent loss, data augmentation |
416
+ | `yolo/` | YOLO | Single-stage object detection, grid-based regression, NMS |
417
  | `nlp/gpt/` | GPT | **Causal Self-Attention**, **KV Cache**, autoregressive generation, word-level tokenizer, temperature + top-k sampling, bad-token blocking |
418
 
419
  ## Setup & Run
 
443
  uv run python -m nlp.seq2seq.train
444
  uv run python -m nlp.seq2seq.generate
445
 
446
+ # Train / Evaluate GCN
447
+ uv run python -m gcn.train
448
+ uv run python -m gcn.eval
449
+
450
+ # Train DQN
451
+ uv run python -m dqn.train
452
+
453
+ # Train SimCLR
454
+ uv run python -m simclr.train
455
+
456
+ # Train YOLO
457
+ uv run python -m yolo.train
458
+
459
  # Train / Generate DDPM
460
  uv run python -m ddpm.train
461
  uv run python -m ddpm.generate
 
521
  | ResNet50 (40 attrs, 200K samples) | `resnet50/resnet50_celeba.pt` | ~90 MB |
522
  | VAE (CelebA, 64×64) | `vae/vae_celeba.pt` | 10 MB |
523
  | Seq2Seq Transformer (Multi30k) | `nlp/seq2seq/seq2seq_multi30k.pt` | 4 MB |
524
+ | GCN (Cora) | `gcn/gcn_cora.pt` | 0.1 MB |
525
+ | DQN (CartPole) | `dqn/dqn_cartpole.pt` | 0.07 MB |
526
+ | SimCLR (CIFAR-10) | `simclr/simclr_cifar10.pt` | 22 MB |
527
+ | YOLO (Pascal VOC) | `yolo/yolo_voc.pt` | 226 MB |
528
  | DDPM (CIFAR-10, 32×32) | `ddpm/ddpm_cifar10.pt` | 62 MB |
529
  | DCGAN (CelebA, 64×64) | `dcgan/dcgan_celeba.pt` | ~23 MB (G+D) |
530
  | ViT (CIFAR-10, 32×32) | `vit/vit_cifar10.pt` | 3.2 MB |
ROADMAP.md CHANGED
@@ -4,25 +4,25 @@
4
 
5
  ## ✅ Completed
6
 
7
- **Models (26):**
8
  - Basics (9): Logistic Regression, Linear Regression, K-Means, SVM (GD + SMO), Decision Tree, Naive Bayes, PCA, k-NN, Perceptron
9
  - Deep Learning (11): MLP (pure NumPy), SimpleCNN, ResNet18, ResNet34, ResNet50 (Bottleneck), BERT, Word2Vec (CBOW + Skip-gram), LSTM (hand-written gates), GPT (Causal Attention + KV Cache), Seq2Seq Transformer (cross-attention), DDPM (Denoising Diffusion)
10
  - Image Generation (1): DCGAN — CelebA 64×64
11
  - Variational AE (1): VAE — Encoder, reparameterization trick, KL divergence, CelebA 64×64
12
  - Vision Transformer (1): ViT — CIFAR-10
13
  - Segmentation (1): UNet — Oxford-IIIT Pet
 
 
 
 
14
 
15
  **Infrastructure:**
16
- - Config system (YAML)
17
- - TensorBoard logging
18
- - Reproducibility (seed + config saving)
19
  - Device auto-detection (`utils.device.get_device`: CUDA → MPS → CPU)
20
 
21
- **Jupyter Notebooks (15):**
22
- - P0: GPT, ViT, DCGAN, UNet
23
- - P1: CNN, BERT, ResNet18, ResNet34
24
- - P2: basics (9)
25
- - P3: Word2Vec, LSTM
26
 
27
  ---
28
 
 
4
 
5
  ## ✅ Completed
6
 
7
+ **Models (30):**
8
  - Basics (9): Logistic Regression, Linear Regression, K-Means, SVM (GD + SMO), Decision Tree, Naive Bayes, PCA, k-NN, Perceptron
9
  - Deep Learning (11): MLP (pure NumPy), SimpleCNN, ResNet18, ResNet34, ResNet50 (Bottleneck), BERT, Word2Vec (CBOW + Skip-gram), LSTM (hand-written gates), GPT (Causal Attention + KV Cache), Seq2Seq Transformer (cross-attention), DDPM (Denoising Diffusion)
10
  - Image Generation (1): DCGAN — CelebA 64×64
11
  - Variational AE (1): VAE — Encoder, reparameterization trick, KL divergence, CelebA 64×64
12
  - Vision Transformer (1): ViT — CIFAR-10
13
  - Segmentation (1): UNet — Oxford-IIIT Pet
14
+ - Graph (1): GCN — Cora citation network, spectral graph convolution
15
+ - Reinforcement Learning (1): DQN — CartPole-v1, experience replay
16
+ - Self-Supervised (1): SimCLR — CIFAR-10, NT-Xent contrastive loss
17
+ - Object Detection (1): YOLO — Pascal VOC, grid-based detection
18
 
19
  **Infrastructure:**
20
+ - Config system (YAML), TensorBoard logging, Reproducibility (seed + config saving)
 
 
21
  - Device auto-detection (`utils.device.get_device`: CUDA → MPS → CPU)
22
 
23
+ **Jupyter Notebooks (19):**
24
+ - Original 15: GPT, ViT, DCGAN, UNet, CNN, BERT, ResNet18, ResNet34, basics (9), Word2Vec, LSTM
25
+ - New 4: GCN, DQN, SimCLR, YOLO
 
 
26
 
27
  ---
28
 
dqn/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .dqn import DQN, ReplayBuffer, train_episode
2
+
3
+ __all__ = ["DQN", "ReplayBuffer", "train_episode"]
dqn/config.yaml ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ seed: 42
2
+ lr: 0.001
3
+ gamma: 0.99
4
+ epsilon_start: 1.0
5
+ epsilon_end: 0.01
6
+ epsilon_decay: 500
7
+ batch_size: 64
8
+ buffer_size: 50000
9
+ target_update: 100
10
+ num_episodes: 500
11
+ hidden_dim: 128
12
+ model_path: dqn/dqn_cartpole.pt
dqn/dqn.ipynb ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "d18ad3b8",
6
+ "metadata": {},
7
+ "source": [
8
+ "# DQN: Deep Q-Network\n",
9
+ "\n",
10
+ "Reinforcement learning with experience replay on CartPole-v1."
11
+ ]
12
+ },
13
+ {
14
+ "cell_type": "markdown",
15
+ "id": "6088c114",
16
+ "metadata": {},
17
+ "source": [
18
+ "## 背景\n",
19
+ "\n",
20
+ "DQN(Mnih et al. 2015)将深度学习与 Q-Learning 结合,首次在 Atari 游戏上达到人类水平。\n",
21
+ "核心创新:\n",
22
+ "\n",
23
+ "- **经验回放(Experience Replay)**:存储过去经验,随机采样训练,打破数据相关性\n",
24
+ "- **目标网络(Target Network)**:固定 Q-target,稳定训练\n",
25
+ "\n",
26
+ "环境:**CartPole-v1** — 控制小车左右移动,保持杆子不倒。\n",
27
+ "状态:4 维(位置、速度、角度、角速度),动作:2 维(左、右)。\n"
28
+ ]
29
+ },
30
+ {
31
+ "cell_type": "markdown",
32
+ "id": "6c5a83de",
33
+ "metadata": {},
34
+ "source": [
35
+ "## 数学原理\n",
36
+ "\n",
37
+ "### Q-Learning\n",
38
+ "\n",
39
+ "$$Q(s, a) \\leftarrow Q(s, a) + \\alpha \\left(r + \\gamma \\max_{a'} Q(s', a') - Q(s, a)\\right)$$\n",
40
+ "\n",
41
+ "### DQN Loss\n",
42
+ "\n",
43
+ "$$\\mathcal{L} = \\mathbb{E}_{(s,a,r,s') \\sim \\mathcal{D}} \\left[\\left(r + \\gamma \\max_{a'} Q_{\\theta^-}(s', a') - Q_\\theta(s, a)\\right)^2\\right]$$\n",
44
+ "\n",
45
+ "- $\\mathcal{D}$: 经验回放缓冲区\n",
46
+ "- $\\theta$: 在线网络参数\n",
47
+ "- $\\theta^-$: 目标网络参数(每隔 $C$ 步复制一次)\n",
48
+ "\n",
49
+ "### ε-greedy 探索\n",
50
+ "\n",
51
+ "$$a = \\begin{cases} \\text{random}, & \\text{概率 } \\varepsilon \\\\ \\arg\\max_a Q(s, a), & \\text{概率 } 1-\\varepsilon \\end{cases}$$\n",
52
+ "\n",
53
+ "ε 随时间指数衰减。\n"
54
+ ]
55
+ },
56
+ {
57
+ "cell_type": "code",
58
+ "execution_count": null,
59
+ "id": "a82a0cbd",
60
+ "metadata": {},
61
+ "outputs": [],
62
+ "source": [
63
+ "import gymnasium as gym\n",
64
+ "import numpy as np\n",
65
+ "import torch\n",
66
+ "import torch.nn as nn\n",
67
+ "import torch.optim as optim\n",
68
+ "\n",
69
+ "from dqn.dqn import DQN, ReplayBuffer, train_episode, epsilon_by_episode\n",
70
+ "from utils.config import load_config\n",
71
+ "\n",
72
+ "device = torch.device(\"mps\" if torch.backends.mps.is_available() else \"cpu\")\n",
73
+ "print(f\"Device: {device}\")\n",
74
+ "\n",
75
+ "env = gym.make(\"CartPole-v1\")\n",
76
+ "state_dim = env.observation_space.shape[0]\n",
77
+ "action_dim = env.action_space.n\n",
78
+ "print(f\"State: {state_dim} Action: {action_dim}\")\n"
79
+ ]
80
+ },
81
+ {
82
+ "cell_type": "code",
83
+ "execution_count": null,
84
+ "id": "707631bd",
85
+ "metadata": {},
86
+ "outputs": [],
87
+ "source": [
88
+ "model = DQN(state_dim, action_dim, hidden_dim=128).to(device)\n",
89
+ "target = DQN(state_dim, action_dim, hidden_dim=128).to(device)\n",
90
+ "target.load_state_dict(model.state_dict())\n",
91
+ "target.eval()\n",
92
+ "print(f\"Parameters: {sum(p.numel() for p in model.parameters()):,}\")\n"
93
+ ]
94
+ },
95
+ {
96
+ "cell_type": "markdown",
97
+ "id": "872fd75b",
98
+ "metadata": {},
99
+ "source": [
100
+ "## 训练\n",
101
+ "\n",
102
+ "> ⏱ 预估耗时:**500 episode × ~0.2s ≈ 2 分钟**(M4 Max)\n"
103
+ ]
104
+ },
105
+ {
106
+ "cell_type": "code",
107
+ "execution_count": null,
108
+ "id": "2efa79de",
109
+ "metadata": {},
110
+ "outputs": [],
111
+ "source": [
112
+ "NUM_EPISODES = 500\n",
113
+ "LR = 0.001\n",
114
+ "GAMMA = 0.99\n",
115
+ "BATCH_SIZE = 64\n",
116
+ "BUFFER_SIZE = 50000\n",
117
+ "TARGET_UPDATE = 100\n",
118
+ "EPSILON_START = 1.0\n",
119
+ "EPSILON_END = 0.01\n",
120
+ "EPSILON_DECAY = 500\n",
121
+ "\n",
122
+ "optimizer = optim.Adam(model.parameters(), lr=LR)\n",
123
+ "buffer = ReplayBuffer(BUFFER_SIZE)\n",
124
+ "rewards = []\n",
125
+ "\n",
126
+ "for episode in range(1, NUM_EPISODES + 1):\n",
127
+ " state, _ = env.reset()\n",
128
+ " episode_reward = 0\n",
129
+ " eps = epsilon_by_episode(episode, EPSILON_START, EPSILON_END, EPSILON_DECAY)\n",
130
+ "\n",
131
+ " while True:\n",
132
+ " if np.random.random() < eps:\n",
133
+ " action = env.action_space.sample()\n",
134
+ " else:\n",
135
+ " with torch.no_grad():\n",
136
+ " q = model(torch.tensor(state, dtype=torch.float32, device=device).unsqueeze(0))\n",
137
+ " action = q.argmax().item()\n",
138
+ "\n",
139
+ " next_state, reward, terminated, truncated, _ = env.step(action)\n",
140
+ " done = terminated or truncated\n",
141
+ " buffer.push(state, action, reward, next_state, done)\n",
142
+ " state = next_state\n",
143
+ " episode_reward += reward\n",
144
+ " _ = train_episode(model, target, optimizer, buffer, BATCH_SIZE, GAMMA)\n",
145
+ " if done:\n",
146
+ " break\n",
147
+ "\n",
148
+ " if episode % TARGET_UPDATE == 0:\n",
149
+ " target.load_state_dict(model.state_dict())\n",
150
+ "\n",
151
+ " rewards.append(episode_reward)\n",
152
+ " if episode % 50 == 0:\n",
153
+ " avg = np.mean(rewards[-50:])\n",
154
+ " print(f\"Episode [{episode:3d}/{NUM_EPISODES}] Reward: {episode_reward:.0f} Avg(50): {avg:.1f} ε: {eps:.3f}\")\n",
155
+ "\n",
156
+ "env.close()\n"
157
+ ]
158
+ },
159
+ {
160
+ "cell_type": "markdown",
161
+ "id": "cff4e844",
162
+ "metadata": {},
163
+ "source": [
164
+ "## Reward 曲线"
165
+ ]
166
+ },
167
+ {
168
+ "cell_type": "code",
169
+ "execution_count": null,
170
+ "id": "562279a0",
171
+ "metadata": {},
172
+ "outputs": [],
173
+ "source": [
174
+ "import matplotlib.pyplot as plt\n",
175
+ "\n",
176
+ "plt.figure(figsize=(8, 4))\n",
177
+ "plt.plot(rewards)\n",
178
+ "plt.xlabel(\"Episode\"); plt.ylabel(\"Total Reward\"); plt.title(\"DQN Training on CartPole\")\n",
179
+ "plt.grid(True)\n",
180
+ "plt.axhline(y=195, color='r', linestyle='--', label='Solved (195)')\n",
181
+ "plt.legend()\n",
182
+ "plt.show()\n"
183
+ ]
184
+ },
185
+ {
186
+ "cell_type": "markdown",
187
+ "id": "84349669",
188
+ "metadata": {},
189
+ "source": [
190
+ "## 思考题\n",
191
+ "\n",
192
+ "1. 为什么需要经验回放(Experience Replay)?在线学习会有什么问题?\n",
193
+ "2. 目标网络(Target Network)解决了什么?如果不固定 target,Q 值会发散吗?\n",
194
+ "3. ϵ-greedy 中的 ϵ 从 1.0 开始衰减有什么含义?\n",
195
+ "4. 如果把 `hidden_dim` 从 128 改到 32,训练速度会怎样?收敛难度呢?\n"
196
+ ]
197
+ }
198
+ ],
199
+ "metadata": {
200
+ "kernelspec": {
201
+ "display_name": "Python 3",
202
+ "language": "python",
203
+ "name": "python3"
204
+ },
205
+ "language_info": {
206
+ "name": "python",
207
+ "version": "3.12.0"
208
+ }
209
+ },
210
+ "nbformat": 4,
211
+ "nbformat_minor": 5
212
+ }
dqn/dqn.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DQN: Deep Q-Network for CartPole-v1."""
2
+
3
+ import random
4
+ import numpy as np
5
+ import torch
6
+ import torch.nn as nn
7
+ import torch.nn.functional as F
8
+
9
+
10
+ class DQN(nn.Module):
11
+ """Q-network: state → action values."""
12
+
13
+ def __init__(self, state_dim, action_dim, hidden_dim=128):
14
+ super().__init__()
15
+ self.net = nn.Sequential(
16
+ nn.Linear(state_dim, hidden_dim),
17
+ nn.ReLU(),
18
+ nn.Linear(hidden_dim, hidden_dim),
19
+ nn.ReLU(),
20
+ nn.Linear(hidden_dim, action_dim),
21
+ )
22
+
23
+ def forward(self, x):
24
+ return self.net(x)
25
+
26
+
27
+ class ReplayBuffer:
28
+ """Experience replay buffer."""
29
+
30
+ def __init__(self, capacity=50000):
31
+ self.capacity = capacity
32
+ self.buffer = []
33
+ self.pos = 0
34
+
35
+ def push(self, state, action, reward, next_state, done):
36
+ if len(self.buffer) < self.capacity:
37
+ self.buffer.append(None)
38
+ self.buffer[self.pos] = (state, action, reward, next_state, done)
39
+ self.pos = (self.pos + 1) % self.capacity
40
+
41
+ def sample(self, batch_size):
42
+ batch = random.sample(self.buffer, batch_size)
43
+ states, actions, rewards, next_states, dones = zip(*batch)
44
+ return (torch.tensor(np.array(states), dtype=torch.float32),
45
+ torch.tensor(actions, dtype=torch.long),
46
+ torch.tensor(rewards, dtype=torch.float32),
47
+ torch.tensor(np.array(next_states), dtype=torch.float32),
48
+ torch.tensor(dones, dtype=torch.float32))
49
+
50
+ def __len__(self):
51
+ return len(self.buffer)
52
+
53
+
54
+ def train_episode(model, target_model, optimizer, replay_buffer, batch_size, gamma):
55
+ """Train DQN on one batch from replay buffer."""
56
+ if len(replay_buffer) < batch_size:
57
+ return 0.0
58
+
59
+ states, actions, rewards, next_states, dones = replay_buffer.sample(batch_size)
60
+
61
+ # Q(s, a)
62
+ q_values = model(states).gather(1, actions.unsqueeze(1)).squeeze(1)
63
+
64
+ # Target: r + γ * max Q'(s', a')
65
+ with torch.no_grad():
66
+ max_next_q = target_model(next_states).max(dim=1)[0]
67
+ target = rewards + gamma * max_next_q * (1 - dones)
68
+
69
+ loss = F.mse_loss(q_values, target)
70
+ optimizer.zero_grad()
71
+ loss.backward()
72
+ optimizer.step()
73
+ return loss.item()
74
+
75
+
76
+ def epsilon_by_episode(episode, epsilon_start, epsilon_end, epsilon_decay):
77
+ """Annealed ε-greedy."""
78
+ return epsilon_end + (epsilon_start - epsilon_end) * np.exp(-1.0 * episode / epsilon_decay)
dqn/train.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DQN training on CartPole-v1."""
2
+
3
+ import numpy as np
4
+ import torch
5
+ import torch.optim as optim
6
+ from torch.utils.tensorboard import SummaryWriter
7
+
8
+ from dqn.dqn import DQN, ReplayBuffer, train_episode, epsilon_by_episode
9
+ from utils.config import load_config, save_config
10
+ from utils.seed import set_seed
11
+ from utils.device import get_device
12
+
13
+
14
+ def train():
15
+ cfg = load_config("dqn/config.yaml")
16
+ set_seed(cfg["seed"])
17
+
18
+ try:
19
+ import gymnasium as gym
20
+ except ImportError:
21
+ print("Installing gymnasium...")
22
+ import subprocess, sys
23
+ subprocess.check_call([sys.executable, "-m", "pip", "install", "gymnasium"])
24
+ import gymnasium as gym
25
+
26
+ device = get_device()
27
+ print(f"Device: {device}")
28
+
29
+ env = gym.make("CartPole-v1")
30
+ state_dim = env.observation_space.shape[0]
31
+ action_dim = env.action_space.n
32
+ print(f"CartPole: state={state_dim}, action={action_dim}")
33
+
34
+ model = DQN(state_dim, action_dim, cfg["hidden_dim"]).to(device)
35
+ target_model = DQN(state_dim, action_dim, cfg["hidden_dim"]).to(device)
36
+ target_model.load_state_dict(model.state_dict())
37
+ target_model.eval()
38
+
39
+ optimizer = optim.Adam(model.parameters(), lr=cfg["lr"])
40
+ replay_buffer = ReplayBuffer(cfg["buffer_size"])
41
+
42
+ num_episodes = cfg["num_episodes"]
43
+ writer = SummaryWriter(log_dir="runs/dqn")
44
+
45
+ episode_rewards = []
46
+
47
+ for episode in range(1, num_episodes + 1):
48
+ state, _ = env.reset()
49
+ episode_reward = 0
50
+ epsilon = epsilon_by_episode(episode, cfg["epsilon_start"], cfg["epsilon_end"], cfg["epsilon_decay"])
51
+
52
+ while True:
53
+ # ε-greedy action selection.
54
+ if np.random.random() < epsilon:
55
+ action = env.action_space.sample()
56
+ else:
57
+ with torch.no_grad():
58
+ q = model(torch.tensor(state, dtype=torch.float32, device=device).unsqueeze(0))
59
+ action = q.argmax().item()
60
+
61
+ next_state, reward, terminated, truncated, _ = env.step(action)
62
+ done = terminated or truncated
63
+ replay_buffer.push(state, action, reward, next_state, done)
64
+
65
+ state = next_state
66
+ episode_reward += reward
67
+
68
+ loss = train_episode(model, target_model, optimizer, replay_buffer, cfg["batch_size"], cfg["gamma"])
69
+
70
+ if done:
71
+ break
72
+
73
+ # Update target network.
74
+ if episode % cfg["target_update"] == 0:
75
+ target_model.load_state_dict(model.state_dict())
76
+
77
+ episode_rewards.append(episode_reward)
78
+ writer.add_scalar("train/reward", episode_reward, episode)
79
+ writer.add_scalar("train/epsilon", epsilon, episode)
80
+
81
+ if episode % 50 == 0:
82
+ avg_reward = np.mean(episode_rewards[-50:])
83
+ print(f"Episode [{episode:4d}/{num_episodes}] Reward: {episode_reward:.0f} Avg(50): {avg_reward:.1f} ε: {epsilon:.3f}")
84
+
85
+ writer.close()
86
+ env.close()
87
+ torch.save(model.state_dict(), cfg["model_path"])
88
+ save_config(cfg, cfg["model_path"].replace(".pt", "_config.yaml"))
89
+ print(f"\nModel saved to {cfg['model_path']}")
90
+
91
+
92
+ if __name__ == "__main__":
93
+ train()
gcn/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .model import GCN
2
+
3
+ __all__ = ["GCN"]
gcn/config.yaml ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ seed: 42
2
+ lr: 0.01
3
+ weight_decay: 5e-4
4
+ hidden_dim: 16
5
+ num_epochs: 200
6
+ dropout: 0.5
7
+ model_path: gcn/gcn_cora.pt
gcn/data.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cora citation network dataset loader."""
2
+
3
+ import os
4
+ import urllib.request
5
+ import numpy as np
6
+ import torch
7
+
8
+
9
+ CORA_URL = "https://raw.githubusercontent.com/tkipf/pygcn/master/data/cora/"
10
+
11
+
12
+ def _download(raw_dir="gcn/raw"):
13
+ os.makedirs(raw_dir, exist_ok=True)
14
+ for fname in ["cora.content", "cora.cites"]:
15
+ path = os.path.join(raw_dir, fname)
16
+ if not os.path.exists(path):
17
+ urllib.request.urlretrieve(CORA_URL + fname, path)
18
+
19
+
20
+ def _encode_onehot(labels):
21
+ classes = sorted(set(labels))
22
+ mapping = {c: i for i, c in enumerate(classes)}
23
+ return torch.eye(len(classes))[torch.tensor([mapping[l] for l in labels])], mapping
24
+
25
+
26
+ def load_cora(raw_dir="gcn/raw"):
27
+ _download(raw_dir)
28
+
29
+ # Parse content: node_id features label
30
+ content = np.genfromtxt(os.path.join(raw_dir, "cora.content"), dtype=np.dtype(str))
31
+ node_ids = content[:, 0].astype(int)
32
+ features = torch.tensor(content[:, 1:-1].astype(np.float32))
33
+ labels_onehot, class_mapping = _encode_onehot(content[:, -1])
34
+ labels = labels_onehot.argmax(dim=1)
35
+
36
+ n = len(node_ids)
37
+ id_to_idx = {int(nid): i for i, nid in enumerate(node_ids)}
38
+
39
+ # Parse cites: source target
40
+ cites = np.genfromtxt(os.path.join(raw_dir, "cora.cites"), dtype=np.int32)
41
+ adj = torch.zeros((n, n), dtype=torch.float32)
42
+ for src, tgt in cites:
43
+ if src in id_to_idx and tgt in id_to_idx:
44
+ adj[id_to_idx[src], id_to_idx[tgt]] = 1.0
45
+ adj[id_to_idx[tgt], id_to_idx[src]] = 1.0 # undirected
46
+
47
+ # Normalize: D^{-1/2} @ A @ D^{-1/2}
48
+ rowsum = adj.sum(dim=1).clamp(min=1e-8)
49
+ d_inv_sqrt = torch.diag(rowsum ** -0.5)
50
+ adj_norm = d_inv_sqrt @ adj @ d_inv_sqrt
51
+
52
+ # Standard splits (20 nodes/class for train, 500 val, 1000 test).
53
+ n_classes = len(class_mapping)
54
+ idx_per_class = [torch.where(labels == c)[0] for c in range(n_classes)]
55
+
56
+ train_idx = torch.cat([idx[:20] for idx in idx_per_class])
57
+ rest = torch.cat([idx[20:] for idx in idx_per_class])
58
+ val_idx = rest[:500]
59
+ test_idx = rest[500:1500]
60
+
61
+ train_mask = torch.zeros(n, dtype=torch.bool)
62
+ val_mask = torch.zeros(n, dtype=torch.bool)
63
+ test_mask = torch.zeros(n, dtype=torch.bool)
64
+ train_mask[train_idx] = True
65
+ val_mask[val_idx] = True
66
+ test_mask[test_idx] = True
67
+
68
+ print(f"Cora: {n} nodes, {features.size(1)} features, {n_classes} classes")
69
+ print(f" Train: {train_idx.size(0)} Val: {val_idx.size(0)} Test: {test_idx.size(0)}")
70
+
71
+ return features, adj_norm, labels, train_mask, val_mask, test_mask, class_mapping
gcn/eval.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+
3
+ from gcn.model import GCN
4
+ from gcn.data import load_cora
5
+ from utils.config import load_config
6
+
7
+
8
+ def evaluate():
9
+ cfg = load_config("gcn/config.yaml")
10
+
11
+ device = torch.device("mps" if torch.backends.mps.is_available() else "cpu")
12
+
13
+ features, adj_norm, labels, _, _, test_mask, class_mapping = load_cora()
14
+ features, adj_norm, labels = features.to(device), adj_norm.to(device), labels.to(device)
15
+ test_mask = test_mask.to(device)
16
+
17
+ model = GCN(
18
+ in_features=features.size(1),
19
+ hidden_dim=cfg["hidden_dim"],
20
+ num_classes=labels.max().item() + 1,
21
+ dropout=cfg["dropout"],
22
+ )
23
+ model.load_state_dict(torch.load(cfg["model_path"], map_location=device, weights_only=True))
24
+ model = model.to(device)
25
+ model.eval()
26
+ print(f"Loaded model from {cfg['model_path']}")
27
+
28
+ with torch.no_grad():
29
+ output = model(features, adj_norm)
30
+ pred = output[test_mask].argmax(dim=1)
31
+ correct = (pred == labels[test_mask]).sum().item()
32
+ total = test_mask.sum().item()
33
+ acc = correct / total
34
+
35
+ print(f"Test Accuracy: {acc:.2%} ({correct}/{total})")
36
+
37
+ id_to_name = {v: k for k, v in class_mapping.items()}
38
+ print(f"\nPer-class accuracy:")
39
+ pred_all = output.argmax(dim=1)
40
+ for c, name in sorted(id_to_name.items()):
41
+ mask = test_mask & (labels == c)
42
+ if mask.sum() > 0:
43
+ acc_c = (pred_all[mask] == labels[mask]).float().mean().item()
44
+ print(f" {name:<15} {acc_c:.2%}")
45
+
46
+
47
+ if __name__ == "__main__":
48
+ evaluate()
gcn/gcn.ipynb ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "f71e99ba",
6
+ "metadata": {},
7
+ "source": [
8
+ "# GCN: Graph Convolutional Network\n",
9
+ "\n",
10
+ "Node classification on citation graphs using spectral graph convolution.\n"
11
+ ]
12
+ },
13
+ {
14
+ "cell_type": "markdown",
15
+ "id": "47197e28",
16
+ "metadata": {},
17
+ "source": [
18
+ "## 背景\n",
19
+ "\n",
20
+ "GCN(Kipf & Welling, 2017)将卷积操作推广到图结构数据。核心思想:\n",
21
+ "每个节点的特征由其邻居节点加权聚合而来。\n",
22
+ "\n",
23
+ "与图像 CNN 的区别:\n",
24
+ "- CNN:固定网格结构,卷积核在空间上滑动\n",
25
+ "- GCN:任意图结构,卷积由邻接矩阵定义的消息传递实现\n",
26
+ "\n",
27
+ "数据集:**Cora** — 2708 篇论文,每篇用 1433 维词袋向量表示,分为 7 类。边表示引用关系。\n"
28
+ ]
29
+ },
30
+ {
31
+ "cell_type": "markdown",
32
+ "id": "f75792e0",
33
+ "metadata": {},
34
+ "source": [
35
+ "## 数学原理\n",
36
+ "\n",
37
+ "### 图卷积层\n",
38
+ "\n",
39
+ "$$H^{(l+1)} = \\sigma\\left(\\hat{A} H^{(l)} W^{(l)}\\right)$$\n",
40
+ "\n",
41
+ "其中 $\\hat{A} = D^{-1/2} A D^{-1/2}$ 是归一化邻接矩阵。\n",
42
+ "\n",
43
+ "- $A$: 邻接矩阵(加自环后)\n",
44
+ "- $D$: 度矩阵 $D_{ii} = \\sum_j A_{ij}$\n",
45
+ "- $H^{(l)}$: 第 $l$ 层的节点表示\n",
46
+ "- $W^{(l)}$: 可学习的权重矩阵\n",
47
+ "\n",
48
+ "### 2 层 GCN\n",
49
+ "\n",
50
+ "$$Z = \\text{softmax}\\left(\\hat{A}\\ \\text{ReLU}\\left(\\hat{A} X W^{(0)}\\right) W^{(1)}\\right)$$\n",
51
+ "\n",
52
+ "半监督学习:只用少量标注节点(每类 20 个)训练,模型通过图结构传播标签信息到未标注节点。\n"
53
+ ]
54
+ },
55
+ {
56
+ "cell_type": "code",
57
+ "execution_count": null,
58
+ "id": "32984352",
59
+ "metadata": {},
60
+ "outputs": [],
61
+ "source": [
62
+ "import torch\n",
63
+ "import torch.nn as nn\n",
64
+ "import torch.optim as optim\n",
65
+ "\n",
66
+ "from gcn.model import GCN\n",
67
+ "from gcn.data import load_cora\n",
68
+ "from utils.config import load_config\n",
69
+ "from utils.seed import set_seed\n",
70
+ "from utils.device import get_device\n",
71
+ "\n",
72
+ "device = get_device()\n",
73
+ "print(f\"Device: {device}\")\n",
74
+ "\n",
75
+ "features, adj_norm, labels, train_mask, val_mask, test_mask, classes = load_cora()\n",
76
+ "features = features.to(device)\n",
77
+ "adj_norm = adj_norm.to(device)\n",
78
+ "labels = labels.to(device)\n",
79
+ "train_mask = train_mask.to(device)\n",
80
+ "val_mask = val_mask.to(device)\n"
81
+ ]
82
+ },
83
+ {
84
+ "cell_type": "code",
85
+ "execution_count": null,
86
+ "id": "419e0b87",
87
+ "metadata": {},
88
+ "outputs": [],
89
+ "source": [
90
+ "model = GCN(\n",
91
+ " in_features=features.size(1),\n",
92
+ " hidden_dim=16,\n",
93
+ " num_classes=labels.max().item() + 1,\n",
94
+ " dropout=0.5,\n",
95
+ ").to(device)\n",
96
+ "print(f\"Parameters: {model.num_params():,}\")\n"
97
+ ]
98
+ },
99
+ {
100
+ "cell_type": "markdown",
101
+ "id": "ce56f902",
102
+ "metadata": {},
103
+ "source": [
104
+ "## 训练\n",
105
+ "\n",
106
+ "> ⏱ 预估耗时:**200 epoch × ~0.1s/epoch ≈ 20 秒**(CPU 即可完成)\n"
107
+ ]
108
+ },
109
+ {
110
+ "cell_type": "code",
111
+ "execution_count": null,
112
+ "id": "4e9179b2",
113
+ "metadata": {},
114
+ "outputs": [],
115
+ "source": [
116
+ "NUM_EPOCHS = 200\n",
117
+ "LR = 0.01\n",
118
+ "WEIGHT_DECAY = 5e-4\n",
119
+ "\n",
120
+ "criterion = nn.CrossEntropyLoss()\n",
121
+ "optimizer = optim.Adam(model.parameters(), lr=LR, weight_decay=WEIGHT_DECAY)\n",
122
+ "\n",
123
+ "loss_hist, acc_hist = [], []\n",
124
+ "\n",
125
+ "for epoch in range(1, NUM_EPOCHS + 1):\n",
126
+ " model.train()\n",
127
+ " optimizer.zero_grad()\n",
128
+ " output = model(features, adj_norm)\n",
129
+ " loss = criterion(output[train_mask], labels[train_mask])\n",
130
+ " loss.backward()\n",
131
+ " optimizer.step()\n",
132
+ "\n",
133
+ " model.eval()\n",
134
+ " with torch.no_grad():\n",
135
+ " output = model(features, adj_norm)\n",
136
+ " val_acc = (output[val_mask].argmax(dim=1) == labels[val_mask]).float().mean().item()\n",
137
+ " loss_hist.append(loss.item())\n",
138
+ " acc_hist.append(val_acc)\n",
139
+ "\n",
140
+ " if epoch % 20 == 0 or epoch == 1:\n",
141
+ " print(f\"Epoch [{epoch:3d}/{NUM_EPOCHS}] Loss: {loss.item():.4f} Val Acc: {val_acc:.2%}\")\n"
142
+ ]
143
+ },
144
+ {
145
+ "cell_type": "markdown",
146
+ "id": "769f23ab",
147
+ "metadata": {},
148
+ "source": [
149
+ "## Loss 曲线"
150
+ ]
151
+ },
152
+ {
153
+ "cell_type": "code",
154
+ "execution_count": null,
155
+ "id": "ad7e46b6",
156
+ "metadata": {},
157
+ "outputs": [],
158
+ "source": [
159
+ "import matplotlib.pyplot as plt\n",
160
+ "\n",
161
+ "fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))\n",
162
+ "ax1.plot(loss_hist); ax1.set_xlabel(\"Epoch\"); ax1.set_ylabel(\"Loss\"); ax1.set_title(\"Training Loss\"); ax1.grid(True)\n",
163
+ "ax2.plot(acc_hist, color='green'); ax2.set_xlabel(\"Epoch\"); ax2.set_ylabel(\"Val Acc\"); ax2.set_title(\"Validation Accuracy\"); ax2.grid(True)\n",
164
+ "plt.tight_layout(); plt.show()\n"
165
+ ]
166
+ },
167
+ {
168
+ "cell_type": "markdown",
169
+ "id": "bf205210",
170
+ "metadata": {},
171
+ "source": [
172
+ "## 测试准确率"
173
+ ]
174
+ },
175
+ {
176
+ "cell_type": "code",
177
+ "execution_count": null,
178
+ "id": "59cc2eac",
179
+ "metadata": {},
180
+ "outputs": [],
181
+ "source": [
182
+ "model.eval()\n",
183
+ "with torch.no_grad():\n",
184
+ " output = model(features, adj_norm)\n",
185
+ " pred = output[test_mask].argmax(dim=1)\n",
186
+ " test_acc = (pred == labels[test_mask]).float().mean().item()\n",
187
+ "print(f\"Test Accuracy: {test_acc:.2%}\")\n"
188
+ ]
189
+ },
190
+ {
191
+ "cell_type": "markdown",
192
+ "id": "a77fac89",
193
+ "metadata": {},
194
+ "source": [
195
+ "## 思考题\n",
196
+ "\n",
197
+ "1. 为什么 GCN 的归一化用 $D^{-1/2} A D^{-1/2}$ 而不是 $D^{-1} A$?\n",
198
+ "2. GCN 能处理归纳式(inductive)任务吗?还是只能直推式(transductive)?\n",
199
+ "3. 如果不用邻接矩阵只用节点特征,准确率会降到多少?\n",
200
+ "4. GCN 层数加深为什么会导致性能下降?(提示:过平滑问题)\n"
201
+ ]
202
+ }
203
+ ],
204
+ "metadata": {
205
+ "kernelspec": {
206
+ "display_name": "Python 3",
207
+ "language": "python",
208
+ "name": "python3"
209
+ },
210
+ "language_info": {
211
+ "name": "python",
212
+ "version": "3.12.0"
213
+ }
214
+ },
215
+ "nbformat": 4,
216
+ "nbformat_minor": 5
217
+ }
gcn/model.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+ import torch.nn.functional as F
4
+
5
+
6
+ class GraphConv(nn.Module):
7
+ """Graph Convolution: H' = σ(Â @ H @ W)"""
8
+
9
+ def __init__(self, in_features, out_features):
10
+ super().__init__()
11
+ self.W = nn.Parameter(torch.randn(in_features, out_features) * 0.01)
12
+
13
+ def forward(self, x, adj_norm):
14
+ # x: (N, in_features), adj_norm: (N, N)
15
+ return adj_norm @ x @ self.W
16
+
17
+
18
+ class GCN(nn.Module):
19
+ """2-layer Graph Convolutional Network (Kipf & Welling, 2017)."""
20
+
21
+ def __init__(self, in_features, hidden_dim, num_classes, dropout=0.5):
22
+ super().__init__()
23
+ self.conv1 = GraphConv(in_features, hidden_dim)
24
+ self.conv2 = GraphConv(hidden_dim, num_classes)
25
+ self.dropout = nn.Dropout(dropout)
26
+
27
+ def forward(self, x, adj_norm):
28
+ x = self.conv1(x, adj_norm)
29
+ x = F.relu(x)
30
+ x = self.dropout(x)
31
+ x = self.conv2(x, adj_norm)
32
+ return x
33
+
34
+ def num_params(self):
35
+ return sum(p.numel() for p in self.parameters())
gcn/train.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+ import torch.optim as optim
4
+ from torch.utils.tensorboard import SummaryWriter
5
+
6
+ from gcn.model import GCN
7
+ from gcn.data import load_cora
8
+ from utils.config import load_config, save_config
9
+ from utils.seed import set_seed
10
+ from utils.device import get_device
11
+
12
+
13
+ def train():
14
+ cfg = load_config("gcn/config.yaml")
15
+ set_seed(cfg["seed"])
16
+
17
+ device = get_device()
18
+ print(f"Device: {device}")
19
+
20
+ features, adj_norm, labels, train_mask, val_mask, test_mask, _ = load_cora()
21
+ features, adj_norm, labels = features.to(device), adj_norm.to(device), labels.to(device)
22
+ train_mask, val_mask = train_mask.to(device), val_mask.to(device)
23
+
24
+ model = GCN(
25
+ in_features=features.size(1),
26
+ hidden_dim=cfg["hidden_dim"],
27
+ num_classes=labels.max().item() + 1,
28
+ dropout=cfg["dropout"],
29
+ ).to(device)
30
+ print(f"Parameters: {model.num_params():,}")
31
+
32
+ criterion = nn.CrossEntropyLoss()
33
+ optimizer = optim.Adam(model.parameters(), lr=cfg["lr"], weight_decay=cfg["weight_decay"])
34
+
35
+ num_epochs = cfg["num_epochs"]
36
+ writer = SummaryWriter(log_dir="runs/gcn")
37
+
38
+ for epoch in range(1, num_epochs + 1):
39
+ model.train()
40
+ optimizer.zero_grad()
41
+ output = model(features, adj_norm)
42
+ loss = criterion(output[train_mask], labels[train_mask])
43
+ loss.backward()
44
+ optimizer.step()
45
+
46
+ with torch.no_grad():
47
+ model.eval()
48
+ output = model(features, adj_norm)
49
+ val_loss = criterion(output[val_mask], labels[val_mask])
50
+ val_acc = (output[val_mask].argmax(dim=1) == labels[val_mask]).float().mean().item()
51
+
52
+ writer.add_scalar("train/loss", loss.item(), epoch)
53
+ writer.add_scalar("val/loss", val_loss.item(), epoch)
54
+ writer.add_scalar("val/acc", val_acc, epoch)
55
+
56
+ if epoch % 10 == 0 or epoch == 1:
57
+ print(f"Epoch [{epoch:3d}/{num_epochs}] Loss: {loss.item():.4f} Val Acc: {val_acc:.2%}")
58
+
59
+ writer.close()
60
+ save_path = cfg["model_path"]
61
+ torch.save(model.state_dict(), save_path)
62
+ save_config(cfg, save_path.replace(".pt", "_config.yaml"))
63
+ print(f"\nModel saved to {save_path}")
64
+
65
+
66
+ if __name__ == "__main__":
67
+ train()
pyproject.toml CHANGED
@@ -15,6 +15,7 @@ dependencies = [
15
  "scikit-learn>=1.0",
16
  "nbformat>=5.0",
17
  "matplotlib>=3.0",
 
18
  "jupyterlab>=4.0",
19
  "ipykernel>=6.0",
20
  ]
 
15
  "scikit-learn>=1.0",
16
  "nbformat>=5.0",
17
  "matplotlib>=3.0",
18
+ "gymnasium>=0.29",
19
  "jupyterlab>=4.0",
20
  "ipykernel>=6.0",
21
  ]
scripts/gen_dqn_notebook.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Generate DQN notebook."""
3
+
4
+ import nbformat as nbf
5
+
6
+ nb = nbf.v4.new_notebook()
7
+ nb.metadata = {"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},"language_info": {"name": "python", "version": "3.12.0"}}
8
+
9
+ cells = []
10
+ def md(s): cells.append(nbf.v4.new_markdown_cell(s))
11
+ def code(s): cells.append(nbf.v4.new_code_cell(s))
12
+
13
+ md("# DQN: Deep Q-Network\n\nReinforcement learning with experience replay on CartPole-v1.")
14
+
15
+ md("""## 背景
16
+
17
+ DQN(Mnih et al. 2015)将深度学习与 Q-Learning 结合,首次在 Atari 游戏上达到人类水平。
18
+ 核心创新:
19
+
20
+ - **经验回放(Experience Replay)**:存储过去经验,随机采样训练,打破数据相关性
21
+ - **目标网络(Target Network)**:固定 Q-target,稳定训练
22
+
23
+ 环境:**CartPole-v1** — 控制小车左右移动,保持杆子不倒。
24
+ 状态:4 维(位置、速度、角度、角速度),动作:2 维(左、右)。
25
+ """)
26
+
27
+ md("""## 数学原理
28
+
29
+ ### Q-Learning
30
+
31
+ $$Q(s, a) \\leftarrow Q(s, a) + \\alpha \\left(r + \\gamma \\max_{a'} Q(s', a') - Q(s, a)\\right)$$
32
+
33
+ ### DQN Loss
34
+
35
+ $$\\mathcal{L} = \\mathbb{E}_{(s,a,r,s') \\sim \\mathcal{D}} \\left[\\left(r + \\gamma \\max_{a'} Q_{\\theta^-}(s', a') - Q_\\theta(s, a)\\right)^2\\right]$$
36
+
37
+ - $\\mathcal{D}$: 经验回放缓冲区
38
+ - $\\theta$: 在线网络参数
39
+ - $\\theta^-$: 目标网络参数(每隔 $C$ 步复制一次)
40
+
41
+ ### ε-greedy 探索
42
+
43
+ $$a = \\begin{cases} \\text{random}, & \\text{概率 } \\varepsilon \\\\ \\arg\\max_a Q(s, a), & \\text{概率 } 1-\\varepsilon \\end{cases}$$
44
+
45
+ ε 随时间指数衰减。
46
+ """)
47
+
48
+ code("""\
49
+ import gymnasium as gym
50
+ import numpy as np
51
+ import torch
52
+ import torch.nn as nn
53
+ import torch.optim as optim
54
+
55
+ from dqn.dqn import DQN, ReplayBuffer, train_episode, epsilon_by_episode
56
+ from utils.config import load_config
57
+
58
+ device = torch.device("mps" if torch.backends.mps.is_available() else "cpu")
59
+ print(f"Device: {device}")
60
+
61
+ env = gym.make("CartPole-v1")
62
+ state_dim = env.observation_space.shape[0]
63
+ action_dim = env.action_space.n
64
+ print(f"State: {state_dim} Action: {action_dim}")
65
+ """)
66
+
67
+ code("""\
68
+ model = DQN(state_dim, action_dim, hidden_dim=128).to(device)
69
+ target = DQN(state_dim, action_dim, hidden_dim=128).to(device)
70
+ target.load_state_dict(model.state_dict())
71
+ target.eval()
72
+ print(f"Parameters: {sum(p.numel() for p in model.parameters()):,}")
73
+ """)
74
+
75
+ md("""## 训练
76
+
77
+ > ⏱ 预估耗时:**500 episode × ~0.2s ≈ 2 分钟**(M4 Max)
78
+ """)
79
+
80
+ code("""\
81
+ NUM_EPISODES = 500
82
+ LR = 0.001
83
+ GAMMA = 0.99
84
+ BATCH_SIZE = 64
85
+ BUFFER_SIZE = 50000
86
+ TARGET_UPDATE = 100
87
+ EPSILON_START = 1.0
88
+ EPSILON_END = 0.01
89
+ EPSILON_DECAY = 500
90
+
91
+ optimizer = optim.Adam(model.parameters(), lr=LR)
92
+ buffer = ReplayBuffer(BUFFER_SIZE)
93
+ rewards = []
94
+
95
+ for episode in range(1, NUM_EPISODES + 1):
96
+ state, _ = env.reset()
97
+ episode_reward = 0
98
+ eps = epsilon_by_episode(episode, EPSILON_START, EPSILON_END, EPSILON_DECAY)
99
+
100
+ while True:
101
+ if np.random.random() < eps:
102
+ action = env.action_space.sample()
103
+ else:
104
+ with torch.no_grad():
105
+ q = model(torch.tensor(state, dtype=torch.float32, device=device).unsqueeze(0))
106
+ action = q.argmax().item()
107
+
108
+ next_state, reward, terminated, truncated, _ = env.step(action)
109
+ done = terminated or truncated
110
+ buffer.push(state, action, reward, next_state, done)
111
+ state = next_state
112
+ episode_reward += reward
113
+ _ = train_episode(model, target, optimizer, buffer, BATCH_SIZE, GAMMA)
114
+ if done:
115
+ break
116
+
117
+ if episode % TARGET_UPDATE == 0:
118
+ target.load_state_dict(model.state_dict())
119
+
120
+ rewards.append(episode_reward)
121
+ if episode % 50 == 0:
122
+ avg = np.mean(rewards[-50:])
123
+ print(f"Episode [{episode:3d}/{NUM_EPISODES}] Reward: {episode_reward:.0f} Avg(50): {avg:.1f} ε: {eps:.3f}")
124
+
125
+ env.close()
126
+ """)
127
+
128
+ md("""## Reward 曲线""")
129
+
130
+ code("""\
131
+ import matplotlib.pyplot as plt
132
+
133
+ plt.figure(figsize=(8, 4))
134
+ plt.plot(rewards)
135
+ plt.xlabel("Episode"); plt.ylabel("Total Reward"); plt.title("DQN Training on CartPole")
136
+ plt.grid(True)
137
+ plt.axhline(y=195, color='r', linestyle='--', label='Solved (195)')
138
+ plt.legend()
139
+ plt.show()
140
+ """)
141
+
142
+ md("""\
143
+ ## 思考题
144
+
145
+ 1. 为什么需要经验回放(Experience Replay)?在线学习会有什么问题?
146
+ 2. 目标网络(Target Network)解决了什么?如果不固定 target,Q 值会发散吗?
147
+ 3. ϵ-greedy 中的 ϵ 从 1.0 开始衰减有什么含义?
148
+ 4. 如果把 `hidden_dim` 从 128 改到 32,训练速度会怎样?收敛难度呢?
149
+ """)
150
+
151
+ nb.cells = cells
152
+ with open("dqn/dqn.ipynb", "w") as f:
153
+ nbf.write(nb, f)
154
+ print("Generated dqn/dqn.ipynb")
scripts/gen_gcn_notebook.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Generate GCN notebook."""
3
+
4
+ import nbformat as nbf
5
+
6
+ nb = nbf.v4.new_notebook()
7
+ nb.metadata = {"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},"language_info": {"name": "python", "version": "3.12.0"}}
8
+
9
+ cells = []
10
+ def md(s): cells.append(nbf.v4.new_markdown_cell(s))
11
+ def code(s): cells.append(nbf.v4.new_code_cell(s))
12
+
13
+ md("# GCN: Graph Convolutional Network\n\nNode classification on citation graphs using spectral graph convolution.\n")
14
+
15
+ md("""## 背景
16
+
17
+ GCN(Kipf & Welling, 2017)将卷积操作推广到图结构数据。核心思想:
18
+ 每个节点的特征由其邻居节点加权聚合而来。
19
+
20
+ 与图像 CNN 的区别:
21
+ - CNN:固定网格结构,卷积核在空间上滑动
22
+ - GCN:任意图结构,卷积由邻接矩阵定义的消息传递实现
23
+
24
+ 数据集:**Cora** — 2708 篇论文,每篇用 1433 维词袋向量表示,分为 7 类。边表示引用关系。
25
+ """)
26
+
27
+ md("""## 数学原理
28
+
29
+ ### 图卷积层
30
+
31
+ $$H^{(l+1)} = \\sigma\\left(\\hat{A} H^{(l)} W^{(l)}\\right)$$
32
+
33
+ 其中 $\\hat{A} = D^{-1/2} A D^{-1/2}$ 是归一化邻接矩阵。
34
+
35
+ - $A$: 邻接矩阵(加自环后)
36
+ - $D$: 度矩阵 $D_{ii} = \\sum_j A_{ij}$
37
+ - $H^{(l)}$: 第 $l$ 层的节点表示
38
+ - $W^{(l)}$: 可学习的权重矩阵
39
+
40
+ ### 2 层 GCN
41
+
42
+ $$Z = \\text{softmax}\\left(\\hat{A}\\ \\text{ReLU}\\left(\\hat{A} X W^{(0)}\\right) W^{(1)}\\right)$$
43
+
44
+ 半监督学习:只用少量标注节点(每类 20 个)训练,模型通过图结构传播标签信息到未标注节点。
45
+ """)
46
+
47
+ code("""\
48
+ import torch
49
+ import torch.nn as nn
50
+ import torch.optim as optim
51
+
52
+ from gcn.model import GCN
53
+ from gcn.data import load_cora
54
+ from utils.config import load_config
55
+ from utils.seed import set_seed
56
+ from utils.device import get_device
57
+
58
+ device = get_device()
59
+ print(f"Device: {device}")
60
+
61
+ features, adj_norm, labels, train_mask, val_mask, test_mask, classes = load_cora()
62
+ features = features.to(device)
63
+ adj_norm = adj_norm.to(device)
64
+ labels = labels.to(device)
65
+ train_mask = train_mask.to(device)
66
+ val_mask = val_mask.to(device)
67
+ """)
68
+
69
+ code("""\
70
+ model = GCN(
71
+ in_features=features.size(1),
72
+ hidden_dim=16,
73
+ num_classes=labels.max().item() + 1,
74
+ dropout=0.5,
75
+ ).to(device)
76
+ print(f"Parameters: {model.num_params():,}")
77
+ """)
78
+
79
+ md("""## 训练
80
+
81
+ > ⏱ 预估耗时:**200 epoch × ~0.1s/epoch ≈ 20 秒**(CPU 即可完成)
82
+ """)
83
+
84
+ code("""\
85
+ NUM_EPOCHS = 200
86
+ LR = 0.01
87
+ WEIGHT_DECAY = 5e-4
88
+
89
+ criterion = nn.CrossEntropyLoss()
90
+ optimizer = optim.Adam(model.parameters(), lr=LR, weight_decay=WEIGHT_DECAY)
91
+
92
+ loss_hist, acc_hist = [], []
93
+
94
+ for epoch in range(1, NUM_EPOCHS + 1):
95
+ model.train()
96
+ optimizer.zero_grad()
97
+ output = model(features, adj_norm)
98
+ loss = criterion(output[train_mask], labels[train_mask])
99
+ loss.backward()
100
+ optimizer.step()
101
+
102
+ model.eval()
103
+ with torch.no_grad():
104
+ output = model(features, adj_norm)
105
+ val_acc = (output[val_mask].argmax(dim=1) == labels[val_mask]).float().mean().item()
106
+ loss_hist.append(loss.item())
107
+ acc_hist.append(val_acc)
108
+
109
+ if epoch % 20 == 0 or epoch == 1:
110
+ print(f"Epoch [{epoch:3d}/{NUM_EPOCHS}] Loss: {loss.item():.4f} Val Acc: {val_acc:.2%}")
111
+ """)
112
+
113
+ md("""## Loss 曲线""")
114
+
115
+ code("""\
116
+ import matplotlib.pyplot as plt
117
+
118
+ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
119
+ ax1.plot(loss_hist); ax1.set_xlabel("Epoch"); ax1.set_ylabel("Loss"); ax1.set_title("Training Loss"); ax1.grid(True)
120
+ ax2.plot(acc_hist, color='green'); ax2.set_xlabel("Epoch"); ax2.set_ylabel("Val Acc"); ax2.set_title("Validation Accuracy"); ax2.grid(True)
121
+ plt.tight_layout(); plt.show()
122
+ """)
123
+
124
+ md("""## 测试准确率""")
125
+
126
+ code("""\
127
+ model.eval()
128
+ with torch.no_grad():
129
+ output = model(features, adj_norm)
130
+ pred = output[test_mask].argmax(dim=1)
131
+ test_acc = (pred == labels[test_mask]).float().mean().item()
132
+ print(f"Test Accuracy: {test_acc:.2%}")
133
+ """)
134
+
135
+ md("""\
136
+ ## 思考题
137
+
138
+ 1. 为什么 GCN 的归一化用 $D^{-1/2} A D^{-1/2}$ 而不是 $D^{-1} A$?
139
+ 2. GCN 能处理归纳式(inductive)任务吗?还是只能直推式(transductive)?
140
+ 3. 如果不用邻接矩阵只用节点特征,准确率会降到多少?
141
+ 4. GCN 层数加深为什么会导致性能下降?(提示:过平滑问题)
142
+ """)
143
+
144
+ nb.cells = cells
145
+ with open("gcn/gcn.ipynb", "w") as f:
146
+ nbf.write(nb, f)
147
+ print("Generated gcn/gcn.ipynb")
scripts/gen_simclr_notebook.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Generate SimCLR notebook."""
3
+
4
+ import nbformat as nbf
5
+
6
+ nb = nbf.v4.new_notebook()
7
+ nb.metadata = {"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},"language_info": {"name": "python", "version": "3.12.0"}}
8
+
9
+ cells = []
10
+ def md(s): cells.append(nbf.v4.new_markdown_cell(s))
11
+ def code(s): cells.append(nbf.v4.new_code_cell(s))
12
+
13
+ md("# SimCLR: Contrastive Learning\n\nSelf-supervised representation learning with NT-Xent loss on CIFAR-10.")
14
+
15
+ md("""## 背景
16
+
17
+ SimCLR(Chen et al. 2020)通过**对比学习**在没有标签的情况下学习图像表示。
18
+ 核心思路:同一张图的不同增强视图应该得到相似的表示,不同图的视图应该不同。
19
+
20
+ 关键组件:
21
+ - **数据增强**:随机裁剪、颜色抖动、高斯模糊、灰度化
22
+ - **Encoder**:ResNet18(去掉最后一层)
23
+ - **Projector**:MLP 将表示投影到对比空间
24
+ - **NT-Xent Loss**:归一化温度标度的交叉熵损失
25
+
26
+ 训练完成后,encoder 可以迁移到下游分类任务,只需加一个线性分类器。
27
+ """)
28
+
29
+ md("""## 数学原理
30
+
31
+ ### NT-Xent Loss
32
+
33
+ 对每个批次 $N$ 张图,生成两个增强视图,共 $2N$ 个样本:
34
+
35
+ $$\ell(i, j) = -\\log \\frac{\\exp(\\text{sim}(z_i, z_j) / \\tau)}{\\sum_{k=1}^{2N} \\mathbb{1}_{[k \\neq i]} \\exp(\\text{sim}(z_i, z_k) / \\tau)}$$
36
+
37
+ 其中 $\\text{sim}(u, v) = \\frac{u^\\top v}{\\|u\\|\\|v\\|}$ 是余弦相似度,$(i, j)$ 是一对正样本(同一图的两种增强)。
38
+ """)
39
+
40
+ code("""\
41
+ import torch
42
+ import torch.optim as optim
43
+ from torch.utils.data import DataLoader
44
+ from torchvision import transforms
45
+ from datasets import load_dataset
46
+
47
+ from simclr.model import SimCLR
48
+ from utils.config import load_config
49
+ from utils.seed import set_seed
50
+ from utils.device import get_device
51
+
52
+ device = get_device()
53
+ print(f"Device: {device}")
54
+ """)
55
+
56
+ code("""\
57
+ from simclr.data import SimCLRTransform, load_cifar10_simclr
58
+
59
+ loader = load_cifar10_simclr(batch_size=256, num_workers=4)
60
+ print(f"Batches per epoch: {len(loader)}")
61
+ """)
62
+
63
+ code("""\
64
+ model = SimCLR(project_dim=128, temperature=0.5).to(device)
65
+ print(f"Parameters: {model.num_params():,}")
66
+
67
+ # Count encoder vs projector params
68
+ enc = sum(p.numel() for p in model.encoder.parameters() if p.requires_grad)
69
+ proj = sum(p.numel() for p in model.projector.parameters() if p.requires_grad)
70
+ print(f" Encoder (ResNet18): {enc:,}")
71
+ print(f" Projector (MLP): {proj:,}")
72
+ """)
73
+
74
+ md("""## 训练
75
+
76
+ > ⏱ 预估耗时:**100 epoch × ~40s/epoch ≈ 1 小时**(M4 Max, batch_size=256)
77
+ > 如果太久,把下面 `NUM_EPOCHS` 改到 10 先看 loss 趋势。
78
+ """)
79
+
80
+ code("""\
81
+ NUM_EPOCHS = 100
82
+ LR = 0.0003
83
+
84
+ optimizer = optim.Adam(model.parameters(), lr=LR)
85
+ loss_hist = []
86
+
87
+ for epoch in range(1, NUM_EPOCHS + 1):
88
+ model.train()
89
+ total_loss = 0.0
90
+ num_batches = 0
91
+ for batch in loader:
92
+ x1, x2 = batch["view1"].to(device), batch["view2"].to(device)
93
+ z1, z2 = model(x1), model(x2)
94
+ loss = model.nt_xent_loss(z1, z2)
95
+ optimizer.zero_grad(); loss.backward(); optimizer.step()
96
+ total_loss += loss.item(); num_batches += 1
97
+
98
+ avg = total_loss / num_batches
99
+ loss_hist.append(avg)
100
+ print(f"Epoch [{epoch:2d}/{NUM_EPOCHS}] Loss: {avg:.4f}")
101
+ """)
102
+
103
+ md("""## Loss 曲线""")
104
+
105
+ code("""\
106
+ import matplotlib.pyplot as plt
107
+ plt.plot(loss_hist)
108
+ plt.xlabel("Epoch"); plt.ylabel("Loss"); plt.title("SimCLR Contrastive Loss"); plt.grid(True)
109
+ plt.show()
110
+ """)
111
+
112
+ md("""\
113
+ ## 思考题
114
+
115
+ 1. SimCLR 为什么需要 Projector?直接用 encoder 的输出做对比学习效果会差吗?
116
+ 2. 数据增强的质量对对比学习有多重要?如果只用翻转,loss 会怎样?
117
+ 3. NT-Xent 中的 temperature $\\tau$ 起什么作用?增大/减小各有什么影响?
118
+ 4. SimCLR 为什么需要大 batch size?(提示:负样本数量)
119
+ """)
120
+
121
+ nb.cells = cells
122
+ with open("simclr/simclr.ipynb", "w") as f:
123
+ nbf.write(nb, f)
124
+ print("Generated simclr/simclr.ipynb")
scripts/gen_yolo_notebook.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Generate YOLO notebook."""
3
+
4
+ import nbformat as nbf
5
+
6
+ nb = nbf.v4.new_notebook()
7
+ nb.metadata = {"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},"language_info": {"name": "python", "version": "3.12.0"}}
8
+
9
+ cells = []
10
+ def md(s): cells.append(nbf.v4.new_markdown_cell(s))
11
+ def code(s): cells.append(nbf.v4.new_code_cell(s))
12
+
13
+ md("# YOLO: You Only Look Once\n\nSimplified object detection with grid-based bounding box regression on Pascal VOC.")
14
+
15
+ md("""## 背景
16
+
17
+ YOLO(Redmon et al. 2016)是首个单阶段目标检测器,将检测视为回归问题。
18
+ 一张图通过 CNN 一次前向传播,直接输出边界框和类别概率。
19
+
20
+ 核心思想:将图像分成 $S \\times S$ 网格,每个网格预测 $B$ 个边界框和 $C$ 个类别的概率。
21
+
22
+ 与两阶段检测器(Faster R-CNN)的区别:
23
+ - YOLO:一次前向 → 端到端,速度快但精度略低
24
+ - Faster R-CNN:候选区域 → 分类,精度高但速度慢
25
+
26
+ 数据集:**Pascal VOC** — 20 类物体,含边界框标注。
27
+ """)
28
+
29
+ md("""## 数学原理
30
+
31
+ ### 输出表示
32
+
33
+ 每个网格单元预测 $B$ 个边界框,每个框 5 个值:
34
+
35
+ $$(x, y, w, h, \\text{confidence})$$
36
+
37
+ - $x, y$: 框中心相对于网格单元的偏移(0~1)
38
+ - $w, h$: 框宽高相对于图像尺寸的比例
39
+ - $\\text{confidence}$: $P(\\text{object}) \\times \\text{IoU}_{\\text{pred}}^{\\text{truth}}$
40
+
41
+ 再加上 $C$ 个类别概率 $P(\\text{class}_i \\mid \\text{object})$
42
+
43
+ 输出张量:$S \\times S \\times (B \\times 5 + C)$
44
+
45
+ ### 损失函数
46
+
47
+ $$\\mathcal{L} = \\lambda_{\\text{coord}} \\sum \\mathbb{1}_{ij}^{\\text{obj}} [(x - \\hat{x})^2 + (y - \\hat{y})^2 + (\\sqrt{w} - \\sqrt{\\hat{w}})^2 + (\\sqrt{h} - \\sqrt{\\hat{h}})^2] + \\sum \\mathbb{1}_{ij}^{\\text{obj}} (C - \\hat{C})^2 + \\lambda_{\\text{noobj}} \\sum \\mathbb{1}_{ij}^{\\text{noobj}} (C - \\hat{C})^2 + \\sum \\mathbb{1}_{i}^{\\text{obj}} \\sum_{c=1}^C (p_i(c) - \\hat{p}_i(c))^2$$
48
+
49
+ ### 非极大值抑制(NMS)
50
+
51
+ 对同一类别的重叠框,保留得分最高的,移除与其 IoU 超过阈值的框。
52
+ """)
53
+
54
+ code("""\
55
+ import torch
56
+ import torch.optim as optim
57
+ from torch.utils.data import DataLoader
58
+ from torchvision import transforms
59
+ from datasets import load_dataset
60
+
61
+ from yolo.model import YOLO
62
+ from yolo.loss import yolo_loss
63
+ from utils.config import load_config
64
+ from utils.seed import set_seed
65
+ from utils.device import get_device
66
+
67
+ device = get_device()
68
+ print(f"Device: {device}")
69
+ """)
70
+
71
+ code("""\
72
+ from yolo.data import load_voc, VOC_CLASSES
73
+
74
+ train_loader, test_loader = load_voc(
75
+ batch_size=32, image_size=224, S=7, B=2, C=20, num_workers=4,
76
+ )
77
+ print(f"Classes ({len(VOC_CLASSES)}): {VOC_CLASSES}")
78
+ print(f"Train batches: {len(train_loader)}")
79
+ """)
80
+
81
+ code("""\
82
+ model = YOLO(S=7, B=2, C=20).to(device)
83
+ print(f"Parameters: {model.num_params():,}")
84
+ """)
85
+
86
+ md("""## 训练
87
+
88
+ > ⏱ 预估耗时:**50 epoch × ~120s/epoch ≈ 1.5 小时**(M4 Max, batch_size=32)
89
+ > 如果太久,把下面 `NUM_EPOCHS` 改到 5 先看 loss 趋势。
90
+ """)
91
+
92
+ code("""\
93
+ NUM_EPOCHS = 50
94
+ LR = 0.0001
95
+
96
+ optimizer = optim.Adam(model.parameters(), lr=LR)
97
+ loss_hist = []
98
+
99
+ for epoch in range(1, NUM_EPOCHS + 1):
100
+ model.train()
101
+ total_loss = 0.0
102
+ for images, targets in train_loader:
103
+ images, targets = images.to(device), targets.to(device)
104
+ pred = model(images)
105
+ loss = yolo_loss(pred, targets, S=7, B=2, C=20, coord_scale=5, noobj_scale=0.5)
106
+ optimizer.zero_grad(); loss.backward(); optimizer.step()
107
+ total_loss += loss.item()
108
+
109
+ avg = total_loss / len(train_loader)
110
+ loss_hist.append(avg)
111
+ print(f"Epoch [{epoch:2d}/{NUM_EPOCHS}] Loss: {avg:.4f}")
112
+ """)
113
+
114
+ md("""## Loss 曲线""")
115
+
116
+ code("""\
117
+ import matplotlib.pyplot as plt
118
+ plt.plot(loss_hist)
119
+ plt.xlabel("Epoch"); plt.ylabel("Loss"); plt.title("YOLO Training Loss"); plt.grid(True)
120
+ plt.show()
121
+ """)
122
+
123
+ md("""\
124
+ ## 思考题
125
+
126
+ 1. YOLO 的 $S \\times S$ 网格中,一个网格只能预测一个物体(每个类)。这对检测小物体有什么影响?
127
+ 2. 为什么边界框的 $w, h$ 用平方根而不是直接用?这有什么物理意义?
128
+ 3. NMS 中的 IoU 阈值高低各有什么影响?
129
+ 4. YOLO 和两阶段检测器(Faster R-CNN)的核心区别是什么?
130
+ """)
131
+
132
+ nb.cells = cells
133
+ with open("yolo/yolo.ipynb", "w") as f:
134
+ nbf.write(nb, f)
135
+ print("Generated yolo/yolo.ipynb")
simclr/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .model import SimCLR
2
+
3
+ __all__ = ["SimCLR"]
simclr/config.yaml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ seed: 42
2
+ batch_size: 256
3
+ lr: 0.0003
4
+ num_epochs: 100
5
+ num_workers: 4
6
+ temperature: 0.5
7
+ project_dim: 128
8
+ model_path: simclr/simclr_cifar10.pt
simclr/data.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CIFAR-10 with SimCLR augmentations (two random views per image)."""
2
+
3
+ from torchvision import transforms
4
+ from torch.utils.data import Dataset
5
+ from datasets import load_dataset
6
+
7
+
8
+ class SimCLRTransform:
9
+ """Random augmentation for SimCLR (crop + color jitter + flip + grayscale)."""
10
+
11
+ def __init__(self, image_size=32):
12
+ self.transform = transforms.Compose([
13
+ transforms.RandomResizedCrop(image_size, scale=(0.2, 1.0)),
14
+ transforms.RandomHorizontalFlip(),
15
+ transforms.ColorJitter(0.8, 0.8, 0.8, 0.2),
16
+ transforms.RandomGrayscale(p=0.2),
17
+ transforms.ToTensor(),
18
+ transforms.Normalize(mean=[0.4914, 0.4822, 0.4465], std=[0.2470, 0.2435, 0.2616]),
19
+ ])
20
+
21
+ def __call__(self, x):
22
+ return self.transform(x), self.transform(x)
23
+
24
+
25
+ def load_cifar10_simclr(batch_size=256, num_workers=4):
26
+ transform = SimCLRTransform()
27
+
28
+ def transform_batch(batch):
29
+ images = [img.convert("RGB") for img in batch["img"]]
30
+ views1, views2 = zip(*[transform(img) for img in images])
31
+ batch["view1"] = list(views1)
32
+ batch["view2"] = list(views2)
33
+ return batch
34
+
35
+ ds = load_dataset("uoft-cs/cifar10", split="train")
36
+ ds.set_transform(transform_batch)
37
+
38
+ from torch.utils.data import DataLoader
39
+ loader = DataLoader(ds, batch_size=batch_size, shuffle=True, num_workers=num_workers)
40
+
41
+ return loader
simclr/model.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+ import torch.nn.functional as F
4
+ from resnet18.model import ResNet, BasicBlock
5
+
6
+
7
+ class Projector(nn.Module):
8
+ """MLP projection head."""
9
+
10
+ def __init__(self, in_dim=512, hidden_dim=256, out_dim=128):
11
+ super().__init__()
12
+ self.net = nn.Sequential(
13
+ nn.Linear(in_dim, hidden_dim),
14
+ nn.BatchNorm1d(hidden_dim),
15
+ nn.ReLU(),
16
+ nn.Linear(hidden_dim, out_dim),
17
+ )
18
+
19
+ def forward(self, x):
20
+ return self.net(x)
21
+
22
+
23
+ class SimCLR(nn.Module):
24
+ """SimCLR: contrastive learning with NT-Xent loss."""
25
+
26
+ def __init__(self, project_dim=128, temperature=0.5):
27
+ super().__init__()
28
+ self.temperature = temperature
29
+ # Encoder: ResNet18 without FC layer.
30
+ self.encoder = ResNet(BasicBlock, [2, 2, 2, 2])
31
+ # Remove the FC layer (avgpool is kept).
32
+ self.encoder.fc = nn.Identity()
33
+ self.projector = Projector(in_dim=512, hidden_dim=256, out_dim=project_dim)
34
+
35
+ def forward(self, x):
36
+ h = self.encoder(x) # (B, 512)
37
+ return self.projector(h) # (B, project_dim)
38
+
39
+ def nt_xent_loss(self, z1, z2):
40
+ """NT-Xent loss between two augmentation views.
41
+
42
+ z1, z2: (B, D) embeddings of two views.
43
+ """
44
+ B = z1.size(0)
45
+ z = torch.cat([z1, z2], dim=0) # (2B, D)
46
+ z = F.normalize(z, dim=1)
47
+
48
+ # Cosine similarity matrix: (2B, 2B)
49
+ sim = z @ z.T / self.temperature
50
+
51
+ # Mask out self-similarity.
52
+ mask = torch.eye(2 * B, device=z.device, dtype=torch.bool)
53
+ sim = sim.masked_fill(mask, float("-inf"))
54
+
55
+ # Positive pairs: (i, i+B) and (i+B, i)
56
+ pos_mask = torch.zeros(2 * B, 2 * B, device=z.device, dtype=torch.bool)
57
+ for i in range(B):
58
+ pos_mask[i, i + B] = True
59
+ pos_mask[i + B, i] = True
60
+
61
+ # Compute loss for all 2B samples.
62
+ pos = sim[pos_mask].view(2 * B, 1)
63
+ neg = sim.masked_fill(pos_mask, float("-inf"))
64
+ logits = torch.cat([pos, neg], dim=1)
65
+ labels = torch.zeros(2 * B, device=z.device, dtype=torch.long)
66
+
67
+ return F.cross_entropy(logits, labels)
68
+
69
+ def num_params(self):
70
+ return sum(p.numel() for p in self.parameters())
simclr/simclr.ipynb ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "403b8e0a",
6
+ "metadata": {},
7
+ "source": [
8
+ "# SimCLR: Contrastive Learning\n",
9
+ "\n",
10
+ "Self-supervised representation learning with NT-Xent loss on CIFAR-10."
11
+ ]
12
+ },
13
+ {
14
+ "cell_type": "markdown",
15
+ "id": "0b4c57e2",
16
+ "metadata": {},
17
+ "source": [
18
+ "## 背景\n",
19
+ "\n",
20
+ "SimCLR(Chen et al. 2020)通过**对比学习**在没有标签的情况下学习图像表示。\n",
21
+ "核心思路:同一张图的不同增强视图应该得到相似的表示,不同图的视图应该不同。\n",
22
+ "\n",
23
+ "关键组件:\n",
24
+ "- **数据增强**:随机裁剪、颜色抖动、高斯模糊、灰度化\n",
25
+ "- **Encoder**:ResNet18(去掉最后一层)\n",
26
+ "- **Projector**:MLP 将表示投影到对比空间\n",
27
+ "- **NT-Xent Loss**:归一化温度标度的交叉熵损失\n",
28
+ "\n",
29
+ "训练完成后,encoder 可以迁移到下游分类任务,只需加一个线性分类器。\n"
30
+ ]
31
+ },
32
+ {
33
+ "cell_type": "markdown",
34
+ "id": "f760a161",
35
+ "metadata": {},
36
+ "source": [
37
+ "## 数学原理\n",
38
+ "\n",
39
+ "### NT-Xent Loss\n",
40
+ "\n",
41
+ "对每个批次 $N$ 张图,生成两个增强视图,共 $2N$ 个样本:\n",
42
+ "\n",
43
+ "$$\\ell(i, j) = -\\log \\frac{\\exp(\\text{sim}(z_i, z_j) / \\tau)}{\\sum_{k=1}^{2N} \\mathbb{1}_{[k \\neq i]} \\exp(\\text{sim}(z_i, z_k) / \\tau)}$$\n",
44
+ "\n",
45
+ "其中 $\\text{sim}(u, v) = \\frac{u^\\top v}{\\|u\\|\\|v\\|}$ 是余弦相似度,$(i, j)$ 是一对正样本(同一图的两种增强)。\n"
46
+ ]
47
+ },
48
+ {
49
+ "cell_type": "code",
50
+ "execution_count": null,
51
+ "id": "a9ed9383",
52
+ "metadata": {},
53
+ "outputs": [],
54
+ "source": [
55
+ "import torch\n",
56
+ "import torch.optim as optim\n",
57
+ "from torch.utils.data import DataLoader\n",
58
+ "from torchvision import transforms\n",
59
+ "from datasets import load_dataset\n",
60
+ "\n",
61
+ "from simclr.model import SimCLR\n",
62
+ "from utils.config import load_config\n",
63
+ "from utils.seed import set_seed\n",
64
+ "from utils.device import get_device\n",
65
+ "\n",
66
+ "device = get_device()\n",
67
+ "print(f\"Device: {device}\")\n"
68
+ ]
69
+ },
70
+ {
71
+ "cell_type": "code",
72
+ "execution_count": null,
73
+ "id": "745f1ad9",
74
+ "metadata": {},
75
+ "outputs": [],
76
+ "source": [
77
+ "from simclr.data import SimCLRTransform, load_cifar10_simclr\n",
78
+ "\n",
79
+ "loader = load_cifar10_simclr(batch_size=256, num_workers=4)\n",
80
+ "print(f\"Batches per epoch: {len(loader)}\")\n"
81
+ ]
82
+ },
83
+ {
84
+ "cell_type": "code",
85
+ "execution_count": null,
86
+ "id": "8684ccbb",
87
+ "metadata": {},
88
+ "outputs": [],
89
+ "source": [
90
+ "model = SimCLR(project_dim=128, temperature=0.5).to(device)\n",
91
+ "print(f\"Parameters: {model.num_params():,}\")\n",
92
+ "\n",
93
+ "# Count encoder vs projector params\n",
94
+ "enc = sum(p.numel() for p in model.encoder.parameters() if p.requires_grad)\n",
95
+ "proj = sum(p.numel() for p in model.projector.parameters() if p.requires_grad)\n",
96
+ "print(f\" Encoder (ResNet18): {enc:,}\")\n",
97
+ "print(f\" Projector (MLP): {proj:,}\")\n"
98
+ ]
99
+ },
100
+ {
101
+ "cell_type": "markdown",
102
+ "id": "c04f9912",
103
+ "metadata": {},
104
+ "source": [
105
+ "## 训练\n",
106
+ "\n",
107
+ "> ⏱ 预估耗时:**100 epoch × ~40s/epoch ≈ 1 小时**(M4 Max, batch_size=256)\n",
108
+ "> 如果太久,把下面 `NUM_EPOCHS` 改到 10 先看 loss 趋势。\n"
109
+ ]
110
+ },
111
+ {
112
+ "cell_type": "code",
113
+ "execution_count": null,
114
+ "id": "1e7d15e7",
115
+ "metadata": {},
116
+ "outputs": [],
117
+ "source": [
118
+ "NUM_EPOCHS = 100\n",
119
+ "LR = 0.0003\n",
120
+ "\n",
121
+ "optimizer = optim.Adam(model.parameters(), lr=LR)\n",
122
+ "loss_hist = []\n",
123
+ "\n",
124
+ "for epoch in range(1, NUM_EPOCHS + 1):\n",
125
+ " model.train()\n",
126
+ " total_loss = 0.0\n",
127
+ " num_batches = 0\n",
128
+ " for batch in loader:\n",
129
+ " x1, x2 = batch[\"view1\"].to(device), batch[\"view2\"].to(device)\n",
130
+ " z1, z2 = model(x1), model(x2)\n",
131
+ " loss = model.nt_xent_loss(z1, z2)\n",
132
+ " optimizer.zero_grad(); loss.backward(); optimizer.step()\n",
133
+ " total_loss += loss.item(); num_batches += 1\n",
134
+ "\n",
135
+ " avg = total_loss / num_batches\n",
136
+ " loss_hist.append(avg)\n",
137
+ " print(f\"Epoch [{epoch:2d}/{NUM_EPOCHS}] Loss: {avg:.4f}\")\n"
138
+ ]
139
+ },
140
+ {
141
+ "cell_type": "markdown",
142
+ "id": "4c81184a",
143
+ "metadata": {},
144
+ "source": [
145
+ "## Loss 曲线"
146
+ ]
147
+ },
148
+ {
149
+ "cell_type": "code",
150
+ "execution_count": null,
151
+ "id": "8d146211",
152
+ "metadata": {},
153
+ "outputs": [],
154
+ "source": [
155
+ "import matplotlib.pyplot as plt\n",
156
+ "plt.plot(loss_hist)\n",
157
+ "plt.xlabel(\"Epoch\"); plt.ylabel(\"Loss\"); plt.title(\"SimCLR Contrastive Loss\"); plt.grid(True)\n",
158
+ "plt.show()\n"
159
+ ]
160
+ },
161
+ {
162
+ "cell_type": "markdown",
163
+ "id": "66c5af41",
164
+ "metadata": {},
165
+ "source": [
166
+ "## 思考题\n",
167
+ "\n",
168
+ "1. SimCLR 为什么需要 Projector?直接用 encoder 的输出做对比学习效果会差吗?\n",
169
+ "2. 数据增强的质量对对比学习有多重要?如果只用翻转,loss 会怎样?\n",
170
+ "3. NT-Xent 中的 temperature $\\tau$ 起什么作用?增大/减小各有什么影响?\n",
171
+ "4. SimCLR 为什么需要大 batch size?(提示:负样本数量)\n"
172
+ ]
173
+ }
174
+ ],
175
+ "metadata": {
176
+ "kernelspec": {
177
+ "display_name": "Python 3",
178
+ "language": "python",
179
+ "name": "python3"
180
+ },
181
+ "language_info": {
182
+ "name": "python",
183
+ "version": "3.12.0"
184
+ }
185
+ },
186
+ "nbformat": 4,
187
+ "nbformat_minor": 5
188
+ }
simclr/train.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.optim as optim
3
+ from torch.utils.tensorboard import SummaryWriter
4
+
5
+ from simclr.model import SimCLR
6
+ from simclr.data import load_cifar10_simclr
7
+ from utils.config import load_config, save_config
8
+ from utils.seed import set_seed
9
+ from utils.device import get_device
10
+
11
+
12
+ def train():
13
+ cfg = load_config("simclr/config.yaml")
14
+ set_seed(cfg["seed"])
15
+
16
+ device = get_device()
17
+ print(f"Device: {device}")
18
+ torch.set_num_threads(4)
19
+
20
+ loader = load_cifar10_simclr(batch_size=cfg["batch_size"], num_workers=cfg["num_workers"])
21
+ print(f"Dataset: 50,000 CIFAR-10 images (with augmentations)")
22
+
23
+ model = SimCLR(project_dim=cfg["project_dim"], temperature=cfg["temperature"]).to(device)
24
+ print(f"Parameters: {model.num_params():,}")
25
+
26
+ optimizer = optim.Adam(model.parameters(), lr=cfg["lr"])
27
+
28
+ num_epochs = cfg["num_epochs"]
29
+ writer = SummaryWriter(log_dir="runs/simclr")
30
+
31
+ for epoch in range(1, num_epochs + 1):
32
+ model.train()
33
+ total_loss = 0.0
34
+ num_batches = 0
35
+
36
+ for batch in loader:
37
+ x1 = batch["view1"].to(device)
38
+ x2 = batch["view2"].to(device)
39
+
40
+ z1 = model(x1)
41
+ z2 = model(x2)
42
+ loss = model.nt_xent_loss(z1, z2)
43
+
44
+ optimizer.zero_grad()
45
+ loss.backward()
46
+ optimizer.step()
47
+
48
+ total_loss += loss.item()
49
+ num_batches += 1
50
+
51
+ avg_loss = total_loss / num_batches
52
+ writer.add_scalar("train/loss", avg_loss, epoch)
53
+ print(f"Epoch [{epoch:2d}/{num_epochs}] Loss: {avg_loss:.4f}")
54
+
55
+ writer.close()
56
+ save_path = cfg["model_path"]
57
+ torch.save(model.state_dict(), save_path)
58
+ save_config(cfg, save_path.replace(".pt", "_config.yaml"))
59
+ print(f"\nModel saved to {save_path}")
60
+
61
+
62
+ if __name__ == "__main__":
63
+ train()
uv.lock CHANGED
@@ -445,6 +445,15 @@ wheels = [
445
  { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
446
  ]
447
 
 
 
 
 
 
 
 
 
 
448
  [[package]]
449
  name = "colorama"
450
  version = "0.4.6"
@@ -534,7 +543,7 @@ name = "cuda-bindings"
534
  version = "13.3.1"
535
  source = { registry = "https://pypi.org/simple" }
536
  dependencies = [
537
- { name = "cuda-pathfinder", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
538
  ]
539
  wheels = [
540
  { url = "https://files.pythonhosted.org/packages/ce/67/5e7dba1ba576dd73da5dee894ca076ca5e959450dfff66d6d510a255d1f7/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7855c4868aabc0cfae28abbe83d56734bdfbd08f08fc234ac1912a12858bf49", size = 6025351, upload-time = "2026-05-29T23:11:49.685Z" },
@@ -565,34 +574,34 @@ wheels = [
565
 
566
  [package.optional-dependencies]
567
  cudart = [
568
- { name = "nvidia-cuda-runtime", marker = "sys_platform == 'linux'" },
569
  ]
570
  cufft = [
571
- { name = "nvidia-cufft", marker = "sys_platform == 'linux'" },
572
  ]
573
  cufile = [
574
- { name = "nvidia-cufile", marker = "sys_platform == 'linux'" },
575
  ]
576
  cupti = [
577
- { name = "nvidia-cuda-cupti", marker = "sys_platform == 'linux'" },
578
  ]
579
  curand = [
580
- { name = "nvidia-curand", marker = "sys_platform == 'linux'" },
581
  ]
582
  cusolver = [
583
- { name = "nvidia-cusolver", marker = "sys_platform == 'linux'" },
584
  ]
585
  cusparse = [
586
- { name = "nvidia-cusparse", marker = "sys_platform == 'linux'" },
587
  ]
588
  nvjitlink = [
589
- { name = "nvidia-nvjitlink", marker = "sys_platform == 'linux'" },
590
  ]
591
  nvrtc = [
592
- { name = "nvidia-cuda-nvrtc", marker = "sys_platform == 'linux'" },
593
  ]
594
  nvtx = [
595
- { name = "nvidia-nvtx", marker = "sys_platform == 'linux'" },
596
  ]
597
 
598
  [[package]]
@@ -683,6 +692,7 @@ version = "0.1.0"
683
  source = { virtual = "." }
684
  dependencies = [
685
  { name = "datasets" },
 
686
  { name = "ipykernel" },
687
  { name = "jupyterlab" },
688
  { name = "matplotlib" },
@@ -699,6 +709,7 @@ dependencies = [
699
  [package.metadata]
700
  requires-dist = [
701
  { name = "datasets", specifier = ">=5.0.0" },
 
702
  { name = "ipykernel", specifier = ">=6.0" },
703
  { name = "jupyterlab", specifier = ">=4.0" },
704
  { name = "matplotlib", specifier = ">=3.0" },
@@ -721,6 +732,15 @@ wheels = [
721
  { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" },
722
  ]
723
 
 
 
 
 
 
 
 
 
 
724
  [[package]]
725
  name = "fastjsonschema"
726
  version = "2.21.2"
@@ -933,6 +953,21 @@ wheels = [
933
  { url = "https://files.pythonhosted.org/packages/ff/b0/4af731ff7492c68a96e4c71bfd0f4590acde92b31c6fe4894e6465c10ff6/grpcio-1.81.1-cp314-cp314-win_amd64.whl", hash = "sha256:3768a5ff1b2125e6f552e561b6b2dca0e64982d8949689b4df145cf8b98d7821", size = 5070275, upload-time = "2026-06-11T12:46:48.486Z" },
934
  ]
935
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
936
  [[package]]
937
  name = "h11"
938
  version = "0.16.0"
@@ -1898,7 +1933,7 @@ name = "nvidia-cublas"
1898
  version = "13.1.1.3"
1899
  source = { registry = "https://pypi.org/simple" }
1900
  dependencies = [
1901
- { name = "nvidia-cuda-nvrtc", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
1902
  ]
1903
  wheels = [
1904
  { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" },
@@ -1937,7 +1972,7 @@ name = "nvidia-cudnn-cu13"
1937
  version = "9.20.0.48"
1938
  source = { registry = "https://pypi.org/simple" }
1939
  dependencies = [
1940
- { name = "nvidia-cublas", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
1941
  ]
1942
  wheels = [
1943
  { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" },
@@ -1949,7 +1984,7 @@ name = "nvidia-cufft"
1949
  version = "12.0.0.61"
1950
  source = { registry = "https://pypi.org/simple" }
1951
  dependencies = [
1952
- { name = "nvidia-nvjitlink", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
1953
  ]
1954
  wheels = [
1955
  { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" },
@@ -1979,9 +2014,9 @@ name = "nvidia-cusolver"
1979
  version = "12.0.4.66"
1980
  source = { registry = "https://pypi.org/simple" }
1981
  dependencies = [
1982
- { name = "nvidia-cublas", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
1983
- { name = "nvidia-cusparse", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
1984
- { name = "nvidia-nvjitlink", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
1985
  ]
1986
  wheels = [
1987
  { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" },
@@ -1993,7 +2028,7 @@ name = "nvidia-cusparse"
1993
  version = "12.6.3.3"
1994
  source = { registry = "https://pypi.org/simple" }
1995
  dependencies = [
1996
- { name = "nvidia-nvjitlink", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
1997
  ]
1998
  wheels = [
1999
  { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" },
@@ -2129,7 +2164,7 @@ name = "pexpect"
2129
  version = "4.9.0"
2130
  source = { registry = "https://pypi.org/simple" }
2131
  dependencies = [
2132
- { name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
2133
  ]
2134
  sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" }
2135
  wheels = [
 
445
  { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
446
  ]
447
 
448
+ [[package]]
449
+ name = "cloudpickle"
450
+ version = "3.1.2"
451
+ source = { registry = "https://pypi.org/simple" }
452
+ sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" }
453
+ wheels = [
454
+ { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" },
455
+ ]
456
+
457
  [[package]]
458
  name = "colorama"
459
  version = "0.4.6"
 
543
  version = "13.3.1"
544
  source = { registry = "https://pypi.org/simple" }
545
  dependencies = [
546
+ { name = "cuda-pathfinder" },
547
  ]
548
  wheels = [
549
  { url = "https://files.pythonhosted.org/packages/ce/67/5e7dba1ba576dd73da5dee894ca076ca5e959450dfff66d6d510a255d1f7/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7855c4868aabc0cfae28abbe83d56734bdfbd08f08fc234ac1912a12858bf49", size = 6025351, upload-time = "2026-05-29T23:11:49.685Z" },
 
574
 
575
  [package.optional-dependencies]
576
  cudart = [
577
+ { name = "nvidia-cuda-runtime" },
578
  ]
579
  cufft = [
580
+ { name = "nvidia-cufft" },
581
  ]
582
  cufile = [
583
+ { name = "nvidia-cufile" },
584
  ]
585
  cupti = [
586
+ { name = "nvidia-cuda-cupti" },
587
  ]
588
  curand = [
589
+ { name = "nvidia-curand" },
590
  ]
591
  cusolver = [
592
+ { name = "nvidia-cusolver" },
593
  ]
594
  cusparse = [
595
+ { name = "nvidia-cusparse" },
596
  ]
597
  nvjitlink = [
598
+ { name = "nvidia-nvjitlink" },
599
  ]
600
  nvrtc = [
601
+ { name = "nvidia-cuda-nvrtc" },
602
  ]
603
  nvtx = [
604
+ { name = "nvidia-nvtx" },
605
  ]
606
 
607
  [[package]]
 
692
  source = { virtual = "." }
693
  dependencies = [
694
  { name = "datasets" },
695
+ { name = "gymnasium" },
696
  { name = "ipykernel" },
697
  { name = "jupyterlab" },
698
  { name = "matplotlib" },
 
709
  [package.metadata]
710
  requires-dist = [
711
  { name = "datasets", specifier = ">=5.0.0" },
712
+ { name = "gymnasium", specifier = ">=0.29" },
713
  { name = "ipykernel", specifier = ">=6.0" },
714
  { name = "jupyterlab", specifier = ">=4.0" },
715
  { name = "matplotlib", specifier = ">=3.0" },
 
732
  { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" },
733
  ]
734
 
735
+ [[package]]
736
+ name = "farama-notifications"
737
+ version = "0.0.6"
738
+ source = { registry = "https://pypi.org/simple" }
739
+ sdist = { url = "https://files.pythonhosted.org/packages/ec/91/14397890dde30adc4bee6462158933806207bc5dd10d7b4d09d5c33845cf/farama_notifications-0.0.6.tar.gz", hash = "sha256:b19acac4bb41d76e59e03394b5dd165f4761c86fa327f56307a35cbee3b60158", size = 2517, upload-time = "2026-04-24T08:43:57.603Z" }
740
+ wheels = [
741
+ { url = "https://files.pythonhosted.org/packages/7c/f0/21f81892e4ed10f4ec3ef2e7cf8635fb76e7c0907c55d0da66be50094760/farama_notifications-0.0.6-py3-none-any.whl", hash = "sha256:f84839188efa1ce5bb361c2a84881b2dc2c0d0d7fb661ff00421820170930935", size = 2897, upload-time = "2026-04-24T08:43:56.785Z" },
742
+ ]
743
+
744
  [[package]]
745
  name = "fastjsonschema"
746
  version = "2.21.2"
 
953
  { url = "https://files.pythonhosted.org/packages/ff/b0/4af731ff7492c68a96e4c71bfd0f4590acde92b31c6fe4894e6465c10ff6/grpcio-1.81.1-cp314-cp314-win_amd64.whl", hash = "sha256:3768a5ff1b2125e6f552e561b6b2dca0e64982d8949689b4df145cf8b98d7821", size = 5070275, upload-time = "2026-06-11T12:46:48.486Z" },
954
  ]
955
 
956
+ [[package]]
957
+ name = "gymnasium"
958
+ version = "1.3.0"
959
+ source = { registry = "https://pypi.org/simple" }
960
+ dependencies = [
961
+ { name = "cloudpickle" },
962
+ { name = "farama-notifications" },
963
+ { name = "numpy" },
964
+ { name = "typing-extensions" },
965
+ ]
966
+ sdist = { url = "https://files.pythonhosted.org/packages/4d/ff/14b6880d703dfaca204490979d3254ccd280c99550798993319902873658/gymnasium-1.3.0.tar.gz", hash = "sha256:6939e86e835d6b71b6ba6bfd360487420876deafc79bfb7bacba83a7c446bcf3", size = 830646, upload-time = "2026-04-22T13:47:14.155Z" }
967
+ wheels = [
968
+ { url = "https://files.pythonhosted.org/packages/e9/73/fda6a25f3beeb5e49d74330b44092b9e5a547395ccd478d1103ddcbff1fc/gymnasium-1.3.0-py3-none-any.whl", hash = "sha256:6b8c159a8540dcbcb221722d7efda24d78ebbcbc3bd2ea1c2611aa2a34471fc2", size = 953904, upload-time = "2026-04-22T13:47:12.13Z" },
969
+ ]
970
+
971
  [[package]]
972
  name = "h11"
973
  version = "0.16.0"
 
1933
  version = "13.1.1.3"
1934
  source = { registry = "https://pypi.org/simple" }
1935
  dependencies = [
1936
+ { name = "nvidia-cuda-nvrtc" },
1937
  ]
1938
  wheels = [
1939
  { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" },
 
1972
  version = "9.20.0.48"
1973
  source = { registry = "https://pypi.org/simple" }
1974
  dependencies = [
1975
+ { name = "nvidia-cublas" },
1976
  ]
1977
  wheels = [
1978
  { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" },
 
1984
  version = "12.0.0.61"
1985
  source = { registry = "https://pypi.org/simple" }
1986
  dependencies = [
1987
+ { name = "nvidia-nvjitlink" },
1988
  ]
1989
  wheels = [
1990
  { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" },
 
2014
  version = "12.0.4.66"
2015
  source = { registry = "https://pypi.org/simple" }
2016
  dependencies = [
2017
+ { name = "nvidia-cublas" },
2018
+ { name = "nvidia-cusparse" },
2019
+ { name = "nvidia-nvjitlink" },
2020
  ]
2021
  wheels = [
2022
  { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" },
 
2028
  version = "12.6.3.3"
2029
  source = { registry = "https://pypi.org/simple" }
2030
  dependencies = [
2031
+ { name = "nvidia-nvjitlink" },
2032
  ]
2033
  wheels = [
2034
  { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" },
 
2164
  version = "4.9.0"
2165
  source = { registry = "https://pypi.org/simple" }
2166
  dependencies = [
2167
+ { name = "ptyprocess" },
2168
  ]
2169
  sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" }
2170
  wheels = [
yolo/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from .model import YOLO
2
+ from .loss import yolo_loss, nms
3
+
4
+ __all__ = ["YOLO", "yolo_loss", "nms"]
yolo/config.yaml ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ seed: 42
2
+ batch_size: 32
3
+ lr: 0.0001
4
+ num_epochs: 50
5
+ num_workers: 4
6
+ image_size: 224
7
+ S: 7 # grid size
8
+ B: 2 # boxes per cell
9
+ C: 20 # Pascal VOC classes
10
+ coord_scale: 5
11
+ noobj_scale: 0.5
12
+ model_path: yolo/yolo_voc.pt
yolo/data.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pascal VOC dataset for YOLO."""
2
+
3
+ import torch
4
+ from torch.utils.data import Dataset, DataLoader
5
+ from torchvision import transforms
6
+ from datasets import load_dataset
7
+
8
+
9
+ VOC_CLASSES = [
10
+ "aeroplane", "bicycle", "bird", "boat", "bottle", "bus", "car",
11
+ "cat", "chair", "cow", "diningtable", "dog", "horse", "motorbike",
12
+ "person", "pottedplant", "sheep", "sofa", "train", "tvmonitor",
13
+ ]
14
+
15
+
16
+ def _build_transform(image_size=224):
17
+ return transforms.Compose([
18
+ transforms.Resize((image_size, image_size)),
19
+ transforms.ToTensor(),
20
+ ])
21
+
22
+
23
+ def _voc_to_yolo(annotation, S, B, C, image_size=224):
24
+ """Convert VOC annotation to YOLO target tensor (S, S, B*5+C)."""
25
+ target = torch.zeros(S, S, B * 5 + C)
26
+
27
+ for obj in annotation:
28
+ try:
29
+ label = obj["category"]
30
+ bbox = obj["bbox"] # [xmin, ymin, xmax, ymax]
31
+ xmin, ymin, xmax, ymax = bbox
32
+ w = xmax - xmin
33
+ h = ymax - ymin
34
+
35
+ # Skip invalid boxes.
36
+ if w <= 0 or h <= 0:
37
+ continue
38
+
39
+ # Center and normalize.
40
+ x_center = (xmin + xmax) / 2 / image_size
41
+ y_center = (ymin + ymax) / 2 / image_size
42
+ w_norm = w / image_size
43
+ h_norm = h / image_size
44
+
45
+ # Grid cell.
46
+ col = int(x_center * S)
47
+ row = int(y_center * S)
48
+ if col >= S or row >= S:
49
+ continue
50
+
51
+ # Relative position within cell.
52
+ x_cell = x_center * S - col
53
+ y_cell = y_center * S - row
54
+
55
+ class_idx = label
56
+ if class_idx < 0 or class_idx >= C:
57
+ continue
58
+
59
+ # Assign to first available box.
60
+ for b in range(B):
61
+ box_start = b * 5
62
+ if target[row, col, box_start + 4] == 0: # confidence unused
63
+ target[row, col, box_start:box_start + 5] = torch.tensor([
64
+ x_cell, y_cell, w_norm, h_norm, 1.0,
65
+ ])
66
+ target[row, col, B * 5 + class_idx] = 1.0
67
+ break
68
+ except (KeyError, TypeError, IndexError):
69
+ continue
70
+
71
+ return target
72
+
73
+
74
+ class VOCDataset(Dataset):
75
+ def __init__(self, split="train", image_size=224, S=7, B=2, C=20):
76
+ self.S, self.B, self.C = S, B, C
77
+ self.image_size = image_size
78
+ self.transform = _build_transform(image_size)
79
+
80
+ ds = load_dataset("widerface/pascal_voc", split="train")
81
+ self.ds = ds
82
+
83
+ def __len__(self):
84
+ return len(self.ds)
85
+
86
+ def __getitem__(self, idx):
87
+ item = self.ds[idx]
88
+ image = item["image"].convert("RGB")
89
+ image = self.transform(image)
90
+
91
+ try:
92
+ objects = item["objects"]
93
+ except (KeyError, TypeError):
94
+ objects = []
95
+
96
+ target = _voc_to_yolo(objects, self.S, self.B, self.C, self.image_size)
97
+ return image, target
98
+
99
+
100
+ def load_voc(batch_size=32, image_size=224, S=7, B=2, C=20, num_workers=4):
101
+ train_dataset = VOCDataset(split="train", image_size=image_size, S=S, B=B, C=C)
102
+ test_dataset = VOCDataset(split="test", image_size=image_size, S=S, B=B, C=C)
103
+
104
+ train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True, num_workers=num_workers)
105
+ test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False, num_workers=num_workers)
106
+
107
+ return train_loader, test_loader
yolo/loss.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """YOLO loss and NMS utilities."""
2
+
3
+ import torch
4
+ import torch.nn as nn
5
+ import torch.nn.functional as F
6
+
7
+
8
+ def yolo_loss(pred, target, S=7, B=2, C=20, coord_scale=5, noobj_scale=0.5):
9
+ """YOLO loss: coord + obj + noobj + class."""
10
+ pred = pred.view(-1, S, S, B * 5 + C)
11
+ target = target.view(-1, S, S, B * 5 + C)
12
+
13
+ obj_mask = target[..., 4] > 0 # (N, S, S) — first box has obj
14
+
15
+ # ── Box coordinates loss (only for obj cells, best box) ──
16
+ coord_loss = torch.tensor(0.0, device=pred.device)
17
+ if obj_mask.any():
18
+ pred_box = pred[obj_mask]
19
+ target_box = target[obj_mask]
20
+ # Take first box for simplicity (would IoU match in real YOLO).
21
+ coord_loss = F.mse_loss(pred_box[:, :4], target_box[:, :4], reduction="sum")
22
+
23
+ # ── Confidence loss ──
24
+ obj_conf = pred[..., 4]
25
+ target_conf = target[..., 4]
26
+ obj_loss = F.mse_loss(obj_conf[obj_mask], target_conf[obj_mask], reduction="sum")
27
+ noobj_loss = F.mse_loss(obj_conf[~obj_mask], target_conf[~obj_mask], reduction="sum")
28
+
29
+ # ── Class loss ──
30
+ class_loss = torch.tensor(0.0, device=pred.device)
31
+ if obj_mask.any():
32
+ class_loss = F.mse_loss(
33
+ pred[obj_mask][:, B * 5:],
34
+ target[obj_mask][:, B * 5:],
35
+ reduction="sum",
36
+ )
37
+
38
+ N = obj_mask.numel()
39
+ return (coord_scale * coord_loss + obj_loss + noobj_scale * noobj_loss + class_loss) / N
40
+
41
+
42
+ def nms(predictions, conf_threshold=0.3, iou_threshold=0.5):
43
+ """Non-Maximum Suppression — simplified for single image."""
44
+ boxes, scores, labels = [], [], []
45
+ S, B, C = 7, 2, 20
46
+
47
+ for row in range(S):
48
+ for col in range(S):
49
+ for b in range(B):
50
+ box = predictions[row, col, b * 5:(b + 1) * 5]
51
+ conf = box[4].item()
52
+ if conf < conf_threshold:
53
+ continue
54
+ x, y, w, h = box[:4].tolist()
55
+ # Convert to absolute coordinates.
56
+ x_abs = (col + x) / S * 224
57
+ y_abs = (row + y) / S * 224
58
+ w_abs = w * 224
59
+ h_abs = h * 224
60
+ x1 = x_abs - w_abs / 2
61
+ y1 = y_abs - h_abs / 2
62
+ x2 = x_abs + w_abs / 2
63
+ y2 = y_abs + h_abs / 2
64
+
65
+ class_probs = predictions[row, col, B * 5:].softmax(dim=0)
66
+ class_score, class_idx = class_probs.max(dim=0)
67
+
68
+ score = conf * class_score.item()
69
+ boxes.append([x1, y1, x2, y2])
70
+ scores.append(score)
71
+ labels.append(class_idx.item())
72
+
73
+ if not boxes:
74
+ return [], [], []
75
+
76
+ boxes = torch.tensor(boxes)
77
+ scores = torch.tensor(scores)
78
+ labels = torch.tensor(labels)
79
+
80
+ # Sort by score.
81
+ _, order = scores.sort(descending=True)
82
+ boxes, scores, labels = boxes[order], scores[order], labels[order]
83
+
84
+ keep = []
85
+ while boxes.size(0) > 0:
86
+ keep.append(0)
87
+ if boxes.size(0) == 1:
88
+ break
89
+ ious = _compute_iou(boxes[0:1], boxes[1:])
90
+ mask = ious < iou_threshold
91
+ boxes, scores, labels = boxes[1:][mask], scores[1:][mask], labels[1:][mask]
92
+
93
+ return boxes[keep], scores[keep], labels[keep]
94
+
95
+
96
+ def _compute_iou(box1, box2):
97
+ x1 = torch.max(box1[:, 0], box2[:, 0])
98
+ y1 = torch.max(box1[:, 1], box2[:, 1])
99
+ x2 = torch.min(box1[:, 2], box2[:, 2])
100
+ y2 = torch.min(box1[:, 3], box2[:, 3])
101
+ inter = (x2 - x1).clamp(0) * (y2 - y1).clamp(0)
102
+ area1 = (box1[:, 2] - box1[:, 0]) * (box1[:, 3] - box1[:, 1])
103
+ area2 = (box2[:, 2] - box2[:, 0]) * (box2[:, 3] - box2[:, 1])
104
+ return inter / (area1 + area2 - inter).clamp(min=1e-8)
yolo/model.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Simplified YOLO: CNN backbone + detection head."""
2
+
3
+ import torch
4
+ import torch.nn as nn
5
+
6
+
7
+ class YOLO(nn.Module):
8
+ """Simplified YOLO detector (like YOLOv1)."""
9
+
10
+ def __init__(self, S=7, B=2, C=20):
11
+ super().__init__()
12
+ self.S, self.B, self.C = S, B, C
13
+
14
+ # CNN backbone: 224 → 112 → 56 → 28 → 14 → 7.
15
+ self.features = nn.Sequential(
16
+ nn.Conv2d(3, 64, 3, 1, 1), nn.BatchNorm2d(64), nn.LeakyReLU(0.1), nn.MaxPool2d(2),
17
+ nn.Conv2d(64, 128, 3, 1, 1), nn.BatchNorm2d(128), nn.LeakyReLU(0.1), nn.MaxPool2d(2),
18
+ nn.Conv2d(128, 256, 3, 1, 1), nn.BatchNorm2d(256), nn.LeakyReLU(0.1), nn.MaxPool2d(2),
19
+ nn.Conv2d(256, 512, 3, 1, 1), nn.BatchNorm2d(512), nn.LeakyReLU(0.1), nn.MaxPool2d(2),
20
+ nn.Conv2d(512, 1024, 3, 1, 1), nn.BatchNorm2d(1024), nn.LeakyReLU(0.1), nn.MaxPool2d(2),
21
+ nn.AdaptiveAvgPool2d((S, S)),
22
+ )
23
+
24
+ # Detection head.
25
+ self.det_head = nn.Sequential(
26
+ nn.Flatten(),
27
+ nn.Linear(1024 * S * S, 1024),
28
+ nn.LeakyReLU(0.1),
29
+ nn.Linear(1024, S * S * (B * 5 + C)),
30
+ )
31
+
32
+ def forward(self, x):
33
+ B = x.size(0)
34
+ x = self.features(x)
35
+ x = self.det_head(x)
36
+ return x.view(B, self.S, self.S, self.B * 5 + self.C)
37
+
38
+ def num_params(self):
39
+ return sum(p.numel() for p in self.parameters())
yolo/train.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.optim as optim
3
+ from torch.utils.tensorboard import SummaryWriter
4
+
5
+ from yolo.model import YOLO
6
+ from yolo.loss import yolo_loss
7
+ from yolo.data import load_voc, VOC_CLASSES
8
+ from utils.config import load_config, save_config
9
+ from utils.seed import set_seed
10
+ from utils.device import get_device
11
+
12
+
13
+ def train():
14
+ cfg = load_config("yolo/config.yaml")
15
+ set_seed(cfg["seed"])
16
+
17
+ device = get_device()
18
+ print(f"Device: {device}")
19
+ torch.set_num_threads(4)
20
+
21
+ train_loader, test_loader = load_voc(
22
+ batch_size=cfg["batch_size"], image_size=cfg["image_size"],
23
+ S=cfg["S"], B=cfg["B"], C=cfg["C"], num_workers=cfg["num_workers"],
24
+ )
25
+ print(f"Train batches: {len(train_loader)}, Test batches: {len(test_loader)}")
26
+
27
+ model = YOLO(S=cfg["S"], B=cfg["B"], C=cfg["C"]).to(device)
28
+ print(f"Parameters: {model.num_params():,}")
29
+
30
+ optimizer = optim.Adam(model.parameters(), lr=cfg["lr"])
31
+
32
+ num_epochs = cfg["num_epochs"]
33
+ writer = SummaryWriter(log_dir="runs/yolo")
34
+
35
+ for epoch in range(1, num_epochs + 1):
36
+ model.train()
37
+ total_loss = 0.0
38
+ num_batches = 0
39
+
40
+ for images, targets in train_loader:
41
+ images, targets = images.to(device), targets.to(device)
42
+ pred = model(images)
43
+ loss = yolo_loss(pred, targets, cfg["S"], cfg["B"], cfg["C"],
44
+ cfg["coord_scale"], cfg["noobj_scale"])
45
+
46
+ optimizer.zero_grad()
47
+ loss.backward()
48
+ optimizer.step()
49
+
50
+ total_loss += loss.item()
51
+ num_batches += 1
52
+
53
+ avg_loss = total_loss / num_batches
54
+ writer.add_scalar("train/loss", avg_loss, epoch)
55
+ print(f"Epoch [{epoch:2d}/{num_epochs}] Loss: {avg_loss:.4f}")
56
+
57
+ writer.close()
58
+ save_path = cfg["model_path"]
59
+ torch.save(model.state_dict(), save_path)
60
+ save_config(cfg, save_path.replace(".pt", "_config.yaml"))
61
+ print(f"\nModel saved to {save_path}")
62
+
63
+
64
+ if __name__ == "__main__":
65
+ train()
yolo/yolo.ipynb ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "e6ec2ac3",
6
+ "metadata": {},
7
+ "source": [
8
+ "# YOLO: You Only Look Once\n",
9
+ "\n",
10
+ "Simplified object detection with grid-based bounding box regression on Pascal VOC."
11
+ ]
12
+ },
13
+ {
14
+ "cell_type": "markdown",
15
+ "id": "f3030108",
16
+ "metadata": {},
17
+ "source": [
18
+ "## 背景\n",
19
+ "\n",
20
+ "YOLO(Redmon et al. 2016)是首个单阶段目标检测器,将检测视为回归问题。\n",
21
+ "一张图通过 CNN 一次前向传播,直接输出边界框和类别概率。\n",
22
+ "\n",
23
+ "核心思想:将图像分成 $S \\times S$ 网格,每个网格预测 $B$ 个边界框和 $C$ 个类别的概率。\n",
24
+ "\n",
25
+ "与两阶段检测器(Faster R-CNN)的区别:\n",
26
+ "- YOLO:一次前向 → 端到端,速度快但精度略低\n",
27
+ "- Faster R-CNN:候选区域 → 分类,精度高但速度慢\n",
28
+ "\n",
29
+ "数据集:**Pascal VOC** — 20 类物体,含边界框标注。\n"
30
+ ]
31
+ },
32
+ {
33
+ "cell_type": "markdown",
34
+ "id": "88a406f0",
35
+ "metadata": {},
36
+ "source": [
37
+ "## 数学原理\n",
38
+ "\n",
39
+ "### 输出表示\n",
40
+ "\n",
41
+ "每个网格单元预测 $B$ 个边界框,每个框 5 个值:\n",
42
+ "\n",
43
+ "$$(x, y, w, h, \\text{confidence})$$\n",
44
+ "\n",
45
+ "- $x, y$: 框中心相对于网格单元的偏移(0~1)\n",
46
+ "- $w, h$: 框宽高相对于图像尺寸的比例\n",
47
+ "- $\\text{confidence}$: $P(\\text{object}) \\times \\text{IoU}_{\\text{pred}}^{\\text{truth}}$\n",
48
+ "\n",
49
+ "再加上 $C$ 个类别概率 $P(\\text{class}_i \\mid \\text{object})$\n",
50
+ "\n",
51
+ "输出张量:$S \\times S \\times (B \\times 5 + C)$\n",
52
+ "\n",
53
+ "### 损失函数\n",
54
+ "\n",
55
+ "$$\\mathcal{L} = \\lambda_{\\text{coord}} \\sum \\mathbb{1}_{ij}^{\\text{obj}} [(x - \\hat{x})^2 + (y - \\hat{y})^2 + (\\sqrt{w} - \\sqrt{\\hat{w}})^2 + (\\sqrt{h} - \\sqrt{\\hat{h}})^2] + \\sum \\mathbb{1}_{ij}^{\\text{obj}} (C - \\hat{C})^2 + \\lambda_{\\text{noobj}} \\sum \\mathbb{1}_{ij}^{\\text{noobj}} (C - \\hat{C})^2 + \\sum \\mathbb{1}_{i}^{\\text{obj}} \\sum_{c=1}^C (p_i(c) - \\hat{p}_i(c))^2$$\n",
56
+ "\n",
57
+ "### 非极大值抑制(NMS)\n",
58
+ "\n",
59
+ "对同一类别的重叠框,保留得分最高的,移除与其 IoU 超过阈值的框。\n"
60
+ ]
61
+ },
62
+ {
63
+ "cell_type": "code",
64
+ "execution_count": null,
65
+ "id": "a9e3b157",
66
+ "metadata": {},
67
+ "outputs": [],
68
+ "source": [
69
+ "import torch\n",
70
+ "import torch.optim as optim\n",
71
+ "from torch.utils.data import DataLoader\n",
72
+ "from torchvision import transforms\n",
73
+ "from datasets import load_dataset\n",
74
+ "\n",
75
+ "from yolo.model import YOLO\n",
76
+ "from yolo.loss import yolo_loss\n",
77
+ "from utils.config import load_config\n",
78
+ "from utils.seed import set_seed\n",
79
+ "from utils.device import get_device\n",
80
+ "\n",
81
+ "device = get_device()\n",
82
+ "print(f\"Device: {device}\")\n"
83
+ ]
84
+ },
85
+ {
86
+ "cell_type": "code",
87
+ "execution_count": null,
88
+ "id": "fcf1335f",
89
+ "metadata": {},
90
+ "outputs": [],
91
+ "source": [
92
+ "from yolo.data import load_voc, VOC_CLASSES\n",
93
+ "\n",
94
+ "train_loader, test_loader = load_voc(\n",
95
+ " batch_size=32, image_size=224, S=7, B=2, C=20, num_workers=4,\n",
96
+ ")\n",
97
+ "print(f\"Classes ({len(VOC_CLASSES)}): {VOC_CLASSES}\")\n",
98
+ "print(f\"Train batches: {len(train_loader)}\")\n"
99
+ ]
100
+ },
101
+ {
102
+ "cell_type": "code",
103
+ "execution_count": null,
104
+ "id": "0c4c1b08",
105
+ "metadata": {},
106
+ "outputs": [],
107
+ "source": [
108
+ "model = YOLO(S=7, B=2, C=20).to(device)\n",
109
+ "print(f\"Parameters: {model.num_params():,}\")\n"
110
+ ]
111
+ },
112
+ {
113
+ "cell_type": "markdown",
114
+ "id": "b23a06f5",
115
+ "metadata": {},
116
+ "source": [
117
+ "## 训练\n",
118
+ "\n",
119
+ "> ⏱ 预估耗时:**50 epoch × ~120s/epoch ≈ 1.5 小时**(M4 Max, batch_size=32)\n",
120
+ "> 如果太久,把下面 `NUM_EPOCHS` 改到 5 先看 loss 趋势。\n"
121
+ ]
122
+ },
123
+ {
124
+ "cell_type": "code",
125
+ "execution_count": null,
126
+ "id": "a0696ca0",
127
+ "metadata": {},
128
+ "outputs": [],
129
+ "source": [
130
+ "NUM_EPOCHS = 50\n",
131
+ "LR = 0.0001\n",
132
+ "\n",
133
+ "optimizer = optim.Adam(model.parameters(), lr=LR)\n",
134
+ "loss_hist = []\n",
135
+ "\n",
136
+ "for epoch in range(1, NUM_EPOCHS + 1):\n",
137
+ " model.train()\n",
138
+ " total_loss = 0.0\n",
139
+ " for images, targets in train_loader:\n",
140
+ " images, targets = images.to(device), targets.to(device)\n",
141
+ " pred = model(images)\n",
142
+ " loss = yolo_loss(pred, targets, S=7, B=2, C=20, coord_scale=5, noobj_scale=0.5)\n",
143
+ " optimizer.zero_grad(); loss.backward(); optimizer.step()\n",
144
+ " total_loss += loss.item()\n",
145
+ "\n",
146
+ " avg = total_loss / len(train_loader)\n",
147
+ " loss_hist.append(avg)\n",
148
+ " print(f\"Epoch [{epoch:2d}/{NUM_EPOCHS}] Loss: {avg:.4f}\")\n"
149
+ ]
150
+ },
151
+ {
152
+ "cell_type": "markdown",
153
+ "id": "4b1965c8",
154
+ "metadata": {},
155
+ "source": [
156
+ "## Loss 曲线"
157
+ ]
158
+ },
159
+ {
160
+ "cell_type": "code",
161
+ "execution_count": null,
162
+ "id": "29b8d6d2",
163
+ "metadata": {},
164
+ "outputs": [],
165
+ "source": [
166
+ "import matplotlib.pyplot as plt\n",
167
+ "plt.plot(loss_hist)\n",
168
+ "plt.xlabel(\"Epoch\"); plt.ylabel(\"Loss\"); plt.title(\"YOLO Training Loss\"); plt.grid(True)\n",
169
+ "plt.show()\n"
170
+ ]
171
+ },
172
+ {
173
+ "cell_type": "markdown",
174
+ "id": "73e98cc8",
175
+ "metadata": {},
176
+ "source": [
177
+ "## 思考题\n",
178
+ "\n",
179
+ "1. YOLO 的 $S \\times S$ 网格中,一个网格只能预测一个物体(每个类)。这对检测小物体有什么影响?\n",
180
+ "2. 为什么边界框的 $w, h$ 用平方根而不是直接用?这有什么物理意义?\n",
181
+ "3. NMS 中的 IoU 阈值高低各有什么影响?\n",
182
+ "4. YOLO 和两阶段检测器(Faster R-CNN)的核心区别是什么?\n"
183
+ ]
184
+ }
185
+ ],
186
+ "metadata": {
187
+ "kernelspec": {
188
+ "display_name": "Python 3",
189
+ "language": "python",
190
+ "name": "python3"
191
+ },
192
+ "language_info": {
193
+ "name": "python",
194
+ "version": "3.12.0"
195
+ }
196
+ },
197
+ "nbformat": 4,
198
+ "nbformat_minor": 5
199
+ }