feat: add GCN, DQN, SimCLR, YOLO (4 new models + notebooks)
Browse files- README.md +83 -1
- ROADMAP.md +9 -9
- dqn/__init__.py +3 -0
- dqn/config.yaml +12 -0
- dqn/dqn.ipynb +212 -0
- dqn/dqn.py +78 -0
- dqn/train.py +93 -0
- gcn/__init__.py +3 -0
- gcn/config.yaml +7 -0
- gcn/data.py +71 -0
- gcn/eval.py +48 -0
- gcn/gcn.ipynb +217 -0
- gcn/model.py +35 -0
- gcn/train.py +67 -0
- pyproject.toml +1 -0
- scripts/gen_dqn_notebook.py +154 -0
- scripts/gen_gcn_notebook.py +147 -0
- scripts/gen_simclr_notebook.py +124 -0
- scripts/gen_yolo_notebook.py +135 -0
- simclr/__init__.py +3 -0
- simclr/config.yaml +8 -0
- simclr/data.py +41 -0
- simclr/model.py +70 -0
- simclr/simclr.ipynb +188 -0
- simclr/train.py +63 -0
- uv.lock +54 -19
- yolo/__init__.py +4 -0
- yolo/config.yaml +12 -0
- yolo/data.py +107 -0
- yolo/loss.py +104 -0
- yolo/model.py +39 -0
- yolo/train.py +65 -0
- yolo/yolo.ipynb +199 -0
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
|
| 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 (
|
| 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 (
|
| 22 |
-
-
|
| 23 |
-
-
|
| 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"
|
| 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"
|
| 569 |
]
|
| 570 |
cufft = [
|
| 571 |
-
{ name = "nvidia-cufft"
|
| 572 |
]
|
| 573 |
cufile = [
|
| 574 |
-
{ name = "nvidia-cufile"
|
| 575 |
]
|
| 576 |
cupti = [
|
| 577 |
-
{ name = "nvidia-cuda-cupti"
|
| 578 |
]
|
| 579 |
curand = [
|
| 580 |
-
{ name = "nvidia-curand"
|
| 581 |
]
|
| 582 |
cusolver = [
|
| 583 |
-
{ name = "nvidia-cusolver"
|
| 584 |
]
|
| 585 |
cusparse = [
|
| 586 |
-
{ name = "nvidia-cusparse"
|
| 587 |
]
|
| 588 |
nvjitlink = [
|
| 589 |
-
{ name = "nvidia-nvjitlink"
|
| 590 |
]
|
| 591 |
nvrtc = [
|
| 592 |
-
{ name = "nvidia-cuda-nvrtc"
|
| 593 |
]
|
| 594 |
nvtx = [
|
| 595 |
-
{ name = "nvidia-nvtx"
|
| 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"
|
| 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"
|
| 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"
|
| 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"
|
| 1983 |
-
{ name = "nvidia-cusparse"
|
| 1984 |
-
{ name = "nvidia-nvjitlink"
|
| 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"
|
| 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"
|
| 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 |
+
}
|