Update RemoteCLIP model package
Browse files- conf/config.yaml +27 -16
- config.json +22 -24
- configuration.json +2 -1
- model/remoteclip.py +79 -45
- scripts/fake_data.py +27 -37
- scripts/inference.py +34 -53
- scripts/result.py +28 -56
- scripts/train.py +126 -100
conf/config.yaml
CHANGED
|
@@ -1,25 +1,36 @@
|
|
| 1 |
seed: 42
|
| 2 |
data:
|
| 3 |
-
|
| 4 |
-
protocol:
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
vocabulary_size:
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
model:
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
text_layers: 2
|
| 16 |
text_heads: 4
|
| 17 |
-
|
| 18 |
-
epochs:
|
| 19 |
-
batch_size:
|
| 20 |
-
learning_rate: 0.
|
| 21 |
-
weight_decay: 0.
|
| 22 |
num_workers: 0
|
|
|
|
|
|
|
|
|
|
| 23 |
paths:
|
| 24 |
checkpoint: result/checkpoints/remoteclip.pt
|
| 25 |
training_metrics: result/training/metrics.json
|
|
|
|
| 1 |
seed: 42
|
| 2 |
data:
|
| 3 |
+
root: data
|
| 4 |
+
protocol: remoteclip_rgb_bpe_pairs_v1
|
| 5 |
+
image_size: 224
|
| 6 |
+
channels: 3
|
| 7 |
+
context_length: 77
|
| 8 |
+
vocabulary_size: 49408
|
| 9 |
+
sot_token_id: 49406
|
| 10 |
+
eot_token_id: 49407
|
| 11 |
+
pad_token_id: 0
|
| 12 |
+
train_samples: 8
|
| 13 |
+
test_samples: 4
|
| 14 |
+
num_semantic_groups: 2
|
| 15 |
model:
|
| 16 |
+
image_size: 224
|
| 17 |
+
patch_size: 32
|
| 18 |
+
embed_dim: 64
|
| 19 |
+
vision_width: 64
|
| 20 |
+
vision_layers: 2
|
| 21 |
+
vision_heads: 4
|
| 22 |
+
text_width: 64
|
| 23 |
text_layers: 2
|
| 24 |
text_heads: 4
|
| 25 |
+
training:
|
| 26 |
+
epochs: 1
|
| 27 |
+
batch_size: 4
|
| 28 |
+
learning_rate: 0.0005
|
| 29 |
+
weight_decay: 0.01
|
| 30 |
num_workers: 0
|
| 31 |
+
runtime:
|
| 32 |
+
device: auto
|
| 33 |
+
amp: true
|
| 34 |
paths:
|
| 35 |
checkpoint: result/checkpoints/remoteclip.pt
|
| 36 |
training_metrics: result/training/metrics.json
|
config.json
CHANGED
|
@@ -5,25 +5,29 @@
|
|
| 5 |
"RemoteCLIP"
|
| 6 |
],
|
| 7 |
"framework": "PyTorch",
|
| 8 |
-
"domain": "earth-
|
| 9 |
"task": "remote-sensing-image-text-retrieval",
|
| 10 |
"implementation": {
|
| 11 |
"entry_point": "model/remoteclip.py",
|
| 12 |
-
"scope": "
|
| 13 |
},
|
| 14 |
"architecture": {
|
| 15 |
-
"family": "
|
| 16 |
-
"
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
"
|
| 22 |
-
"
|
| 23 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
"text_layers": 2,
|
| 25 |
-
"text_heads": 4
|
| 26 |
-
"training_objective": "bidirectional InfoNCE"
|
| 27 |
},
|
| 28 |
"data": {
|
| 29 |
"datasets": [
|
|
@@ -31,19 +35,13 @@
|
|
| 31 |
"RSICD",
|
| 32 |
"UCM-Captions"
|
| 33 |
],
|
| 34 |
-
"
|
| 35 |
-
"
|
| 36 |
-
"
|
| 37 |
-
"
|
| 38 |
},
|
| 39 |
-
"metrics": [
|
| 40 |
-
"image_to_text_r1",
|
| 41 |
-
"image_to_text_r5",
|
| 42 |
-
"text_to_image_r1",
|
| 43 |
-
"text_to_image_r5",
|
| 44 |
-
"mean_recall"
|
| 45 |
-
],
|
| 46 |
"configuration_sources": [
|
|
|
|
| 47 |
"conf/config.yaml",
|
| 48 |
"model/remoteclip.py"
|
| 49 |
]
|
|
|
|
| 5 |
"RemoteCLIP"
|
| 6 |
],
|
| 7 |
"framework": "PyTorch",
|
| 8 |
+
"domain": "earth-observation",
|
| 9 |
"task": "remote-sensing-image-text-retrieval",
|
| 10 |
"implementation": {
|
| 11 |
"entry_point": "model/remoteclip.py",
|
| 12 |
+
"scope": "CLIP-compatible vision and text dual encoders with multi-positive symmetric contrastive learning"
|
| 13 |
},
|
| 14 |
"architecture": {
|
| 15 |
+
"family": "vision transformer and causal text transformer dual encoder",
|
| 16 |
+
"image_shape": [
|
| 17 |
+
3,
|
| 18 |
+
224,
|
| 19 |
+
224
|
| 20 |
+
],
|
| 21 |
+
"patch_size": 32,
|
| 22 |
+
"context_length": 77,
|
| 23 |
+
"vocabulary_size": 49408,
|
| 24 |
+
"embed_dim": 64,
|
| 25 |
+
"vision_width": 64,
|
| 26 |
+
"vision_layers": 2,
|
| 27 |
+
"vision_heads": 4,
|
| 28 |
+
"text_width": 64,
|
| 29 |
"text_layers": 2,
|
| 30 |
+
"text_heads": 4
|
|
|
|
| 31 |
},
|
| 32 |
"data": {
|
| 33 |
"datasets": [
|
|
|
|
| 35 |
"RSICD",
|
| 36 |
"UCM-Captions"
|
| 37 |
],
|
| 38 |
+
"protocol": "remoteclip_rgb_bpe_pairs_v1",
|
| 39 |
+
"input_format": "image_text_npz",
|
| 40 |
+
"train_samples": 8,
|
| 41 |
+
"test_samples": 4
|
| 42 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
"configuration_sources": [
|
| 44 |
+
"configuration.json",
|
| 45 |
"conf/config.yaml",
|
| 46 |
"model/remoteclip.py"
|
| 47 |
]
|
configuration.json
CHANGED
|
@@ -2,8 +2,9 @@
|
|
| 2 |
"framework": "PyTorch",
|
| 3 |
"task": "remote_sensing_image_text_retrieval",
|
| 4 |
"model": "RemoteCLIP",
|
|
|
|
| 5 |
"input_format": "image_text_npz",
|
| 6 |
-
"protocol": "
|
| 7 |
"default_config": "conf/config.yaml",
|
| 8 |
"train": "scripts/train.py",
|
| 9 |
"inference": "scripts/inference.py",
|
|
|
|
| 2 |
"framework": "PyTorch",
|
| 3 |
"task": "remote_sensing_image_text_retrieval",
|
| 4 |
"model": "RemoteCLIP",
|
| 5 |
+
"license": "Apache-2.0",
|
| 6 |
"input_format": "image_text_npz",
|
| 7 |
+
"protocol": "remoteclip_rgb_bpe_pairs_v1",
|
| 8 |
"default_config": "conf/config.yaml",
|
| 9 |
"train": "scripts/train.py",
|
| 10 |
"inference": "scripts/inference.py",
|
model/remoteclip.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
import math
|
| 4 |
|
|
@@ -7,66 +7,100 @@ from torch import nn
|
|
| 7 |
from torch.nn import functional as F
|
| 8 |
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
class RemoteCLIP(nn.Module):
|
|
|
|
|
|
|
| 11 |
def __init__(
|
| 12 |
self,
|
| 13 |
-
vocabulary_size=
|
| 14 |
-
context_length=
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
text_layers=2,
|
| 19 |
text_heads=4,
|
| 20 |
):
|
| 21 |
super().__init__()
|
| 22 |
self.context_length = context_length
|
| 23 |
-
self.
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
nn.Conv2d(vision_width, vision_width * 2, 3, stride=2, padding=1),
|
| 27 |
-
nn.GELU(),
|
| 28 |
-
nn.AdaptiveAvgPool2d(1),
|
| 29 |
-
nn.Flatten(),
|
| 30 |
-
nn.Linear(vision_width * 2, embed_dim),
|
| 31 |
)
|
| 32 |
self.token_embedding = nn.Embedding(vocabulary_size, text_width, padding_idx=0)
|
| 33 |
-
self.position_embedding = nn.Parameter(torch.
|
| 34 |
-
|
| 35 |
-
text_width,
|
| 36 |
-
|
| 37 |
-
text_width * 4,
|
| 38 |
-
dropout=0.0,
|
| 39 |
-
activation="gelu",
|
| 40 |
-
batch_first=True,
|
| 41 |
-
norm_first=True,
|
| 42 |
)
|
| 43 |
-
self.
|
| 44 |
-
self.
|
|
|
|
| 45 |
self.logit_scale = nn.Parameter(torch.tensor(math.log(1 / 0.07)))
|
| 46 |
nn.init.normal_(self.position_embedding, std=0.01)
|
|
|
|
| 47 |
|
| 48 |
def encode_image(self, images):
|
| 49 |
-
|
|
|
|
|
|
|
| 50 |
|
| 51 |
def encode_text(self, tokens):
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
def forward(self, images, tokens):
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
|
|
|
| 1 |
+
"""RemoteCLIP dual encoder with ViT and causal text Transformer."""
|
| 2 |
|
| 3 |
import math
|
| 4 |
|
|
|
|
| 7 |
from torch.nn import functional as F
|
| 8 |
|
| 9 |
|
| 10 |
+
class VisionTransformer(nn.Module):
|
| 11 |
+
def __init__(self, image_size, patch_size, width, layers, heads, output_dim):
|
| 12 |
+
super().__init__()
|
| 13 |
+
if image_size % patch_size:
|
| 14 |
+
raise ValueError("image_size must be divisible by patch_size")
|
| 15 |
+
patches = (image_size // patch_size) ** 2
|
| 16 |
+
self.patch_embed = nn.Conv2d(3, width, patch_size, patch_size, bias=False)
|
| 17 |
+
self.class_embedding = nn.Parameter(torch.empty(1, 1, width))
|
| 18 |
+
self.position_embedding = nn.Parameter(torch.empty(1, patches + 1, width))
|
| 19 |
+
layer = nn.TransformerEncoderLayer(
|
| 20 |
+
width, heads, width * 4, activation="gelu", batch_first=True,
|
| 21 |
+
norm_first=True, dropout=0.0,
|
| 22 |
+
)
|
| 23 |
+
self.transformer = nn.TransformerEncoder(layer, layers)
|
| 24 |
+
self.norm = nn.LayerNorm(width)
|
| 25 |
+
self.projection = nn.Parameter(torch.empty(width, output_dim))
|
| 26 |
+
nn.init.normal_(self.class_embedding, std=width ** -0.5)
|
| 27 |
+
nn.init.normal_(self.position_embedding, std=width ** -0.5)
|
| 28 |
+
nn.init.normal_(self.projection, std=width ** -0.5)
|
| 29 |
+
|
| 30 |
+
def forward(self, images):
|
| 31 |
+
tokens = self.patch_embed(images).flatten(2).transpose(1, 2)
|
| 32 |
+
cls = self.class_embedding.expand(images.shape[0], -1, -1)
|
| 33 |
+
tokens = torch.cat((cls, tokens), dim=1) + self.position_embedding
|
| 34 |
+
return self.norm(self.transformer(tokens)[:, 0]) @ self.projection
|
| 35 |
+
|
| 36 |
+
|
| 37 |
class RemoteCLIP(nn.Module):
|
| 38 |
+
"""CLIP-compatible encoders; EOT is the largest token id in each sequence."""
|
| 39 |
+
|
| 40 |
def __init__(
|
| 41 |
self,
|
| 42 |
+
vocabulary_size=49408,
|
| 43 |
+
context_length=77,
|
| 44 |
+
eot_token_id=49407,
|
| 45 |
+
image_size=224,
|
| 46 |
+
patch_size=32,
|
| 47 |
+
embed_dim=64,
|
| 48 |
+
vision_width=64,
|
| 49 |
+
vision_layers=2,
|
| 50 |
+
vision_heads=4,
|
| 51 |
+
text_width=64,
|
| 52 |
text_layers=2,
|
| 53 |
text_heads=4,
|
| 54 |
):
|
| 55 |
super().__init__()
|
| 56 |
self.context_length = context_length
|
| 57 |
+
self.eot_token_id = eot_token_id
|
| 58 |
+
self.visual = VisionTransformer(
|
| 59 |
+
image_size, patch_size, vision_width, vision_layers, vision_heads, embed_dim
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
)
|
| 61 |
self.token_embedding = nn.Embedding(vocabulary_size, text_width, padding_idx=0)
|
| 62 |
+
self.position_embedding = nn.Parameter(torch.empty(context_length, text_width))
|
| 63 |
+
text_layer = nn.TransformerEncoderLayer(
|
| 64 |
+
text_width, text_heads, text_width * 4, activation="gelu",
|
| 65 |
+
batch_first=True, norm_first=True, dropout=0.0,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
)
|
| 67 |
+
self.text_transformer = nn.TransformerEncoder(text_layer, text_layers)
|
| 68 |
+
self.text_norm = nn.LayerNorm(text_width)
|
| 69 |
+
self.text_projection = nn.Parameter(torch.empty(text_width, embed_dim))
|
| 70 |
self.logit_scale = nn.Parameter(torch.tensor(math.log(1 / 0.07)))
|
| 71 |
nn.init.normal_(self.position_embedding, std=0.01)
|
| 72 |
+
nn.init.normal_(self.text_projection, std=text_width ** -0.5)
|
| 73 |
|
| 74 |
def encode_image(self, images):
|
| 75 |
+
if images.ndim != 4 or images.shape[1:] != (3, 224, 224):
|
| 76 |
+
raise ValueError("images must have paper-compatible shape [B,3,224,224]")
|
| 77 |
+
return F.normalize(self.visual(images), dim=-1)
|
| 78 |
|
| 79 |
def encode_text(self, tokens):
|
| 80 |
+
if tokens.ndim != 2 or tokens.shape[1] != self.context_length:
|
| 81 |
+
raise ValueError(f"tokens must have shape [B,{self.context_length}]")
|
| 82 |
+
causal_mask = torch.full(
|
| 83 |
+
(self.context_length, self.context_length), float("-inf"), device=tokens.device
|
| 84 |
+
).triu_(1)
|
| 85 |
+
features = self.token_embedding(tokens) + self.position_embedding
|
| 86 |
+
features = self.text_norm(self.text_transformer(features, mask=causal_mask))
|
| 87 |
+
eot_positions = tokens.eq(self.eot_token_id).to(torch.int64).argmax(dim=-1)
|
| 88 |
+
pooled = features[torch.arange(tokens.shape[0], device=tokens.device), eot_positions]
|
| 89 |
+
return F.normalize(pooled @ self.text_projection, dim=-1)
|
| 90 |
|
| 91 |
def forward(self, images, tokens):
|
| 92 |
+
return self.encode_image(images), self.encode_text(tokens), self.logit_scale.exp().clamp(max=100)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def multi_positive_clip_loss(image_features, text_features, pair_ids, logit_scale):
|
| 96 |
+
"""Symmetric CLIP loss where all samples sharing pair_id are positives."""
|
| 97 |
+
logits = logit_scale * image_features @ text_features.t()
|
| 98 |
+
positives = pair_ids[:, None].eq(pair_ids[None, :])
|
| 99 |
+
log_i = F.log_softmax(logits, dim=1)
|
| 100 |
+
log_t = F.log_softmax(logits.t(), dim=1)
|
| 101 |
+
loss_i = -(log_i.masked_fill(~positives, 0).sum(1) / positives.sum(1))
|
| 102 |
+
loss_t = -(log_t.masked_fill(~positives.t(), 0).sum(1) / positives.t().sum(1))
|
| 103 |
+
return (loss_i.mean() + loss_t.mean()) / 2
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
__all__ = ["RemoteCLIP", "multi_positive_clip_loss"]
|
scripts/fake_data.py
CHANGED
|
@@ -1,54 +1,44 @@
|
|
| 1 |
-
"""Generate
|
| 2 |
|
|
|
|
| 3 |
from pathlib import Path
|
| 4 |
|
| 5 |
import numpy as np
|
| 6 |
import yaml
|
| 7 |
|
| 8 |
-
|
| 9 |
ROOT = Path(__file__).resolve().parents[1]
|
| 10 |
|
| 11 |
|
| 12 |
def make_split(count, config, seed):
|
| 13 |
rng = np.random.default_rng(seed)
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
images[index] = np.clip(image + rng.normal(0, 0.02, image.shape), 0, 1)
|
| 26 |
-
tokens[index, :4] = [label + 1, 16 + label, 32 + label, 48 + label]
|
| 27 |
-
return images, tokens, labels
|
| 28 |
|
| 29 |
|
| 30 |
def main():
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
data_source=np.asarray("synthetic"),
|
| 46 |
-
protocol=np.asarray(config["data"]["protocol"]),
|
| 47 |
-
)
|
| 48 |
-
print(
|
| 49 |
-
f"generated={output.relative_to(ROOT)} train={len(train[0])} test={len(test[0])} "
|
| 50 |
-
f"data_source=synthetic protocol={config['data']['protocol']}"
|
| 51 |
-
)
|
| 52 |
|
| 53 |
|
| 54 |
if __name__ == "__main__":
|
|
|
|
| 1 |
+
"""Generate tiny paper-shape RemoteCLIP train/test NPZ files."""
|
| 2 |
|
| 3 |
+
import json
|
| 4 |
from pathlib import Path
|
| 5 |
|
| 6 |
import numpy as np
|
| 7 |
import yaml
|
| 8 |
|
|
|
|
| 9 |
ROOT = Path(__file__).resolve().parents[1]
|
| 10 |
|
| 11 |
|
| 12 |
def make_split(count, config, seed):
|
| 13 |
rng = np.random.default_rng(seed)
|
| 14 |
+
d = config["data"]
|
| 15 |
+
images = np.empty((count, 3, 224, 224), dtype=np.float32)
|
| 16 |
+
tokens = np.zeros((count, 77), dtype=np.int64)
|
| 17 |
+
pair_ids = np.arange(count, dtype=np.int64) % d["num_semantic_groups"]
|
| 18 |
+
yy, xx = np.mgrid[:224, :224].astype(np.float32) / 223
|
| 19 |
+
for index, pair_id in enumerate(pair_ids):
|
| 20 |
+
base = np.stack((xx, yy, (xx + yy) / 2)) if pair_id == 0 else np.stack((yy, 1 - xx, xx * yy))
|
| 21 |
+
images[index] = np.clip(base + rng.normal(0, 0.025, base.shape), 0, 1)
|
| 22 |
+
# CLIP convention: EOT has the largest vocabulary id and therefore wins argmax pooling.
|
| 23 |
+
tokens[index, :6] = [49406, 100 + pair_id, 200 + pair_id, 300 + index, 400 + pair_id, 49407]
|
| 24 |
+
return images, tokens, pair_ids
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
|
| 27 |
def main():
|
| 28 |
+
config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
|
| 29 |
+
output = ROOT / config["data"]["root"]
|
| 30 |
+
output.mkdir(parents=True, exist_ok=True)
|
| 31 |
+
for split, count, seed in (("train", config["data"]["train_samples"], config["seed"]),
|
| 32 |
+
("test", config["data"]["test_samples"], config["seed"] + 1)):
|
| 33 |
+
images, tokens, pair_ids = make_split(count, config, seed)
|
| 34 |
+
np.savez_compressed(output / f"{split}.npz", images=images, tokens=tokens, pair_ids=pair_ids,
|
| 35 |
+
data_source=np.asarray("synthetic"), protocol=np.asarray(config["data"]["protocol"]))
|
| 36 |
+
(output / "format.json").write_text(json.dumps({
|
| 37 |
+
"protocol": config["data"]["protocol"], "data_source": "synthetic",
|
| 38 |
+
"images": "float32 [N,3,224,224] in [0,1]", "tokens": "int64 [N,77] CLIP BPE ids",
|
| 39 |
+
"pair_ids": "int64 [N], equal ids define valid multi-positive matches"
|
| 40 |
+
}, indent=2) + "\n")
|
| 41 |
+
print(f"created {output / 'train.npz'} and {output / 'test.npz'}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
|
| 44 |
if __name__ == "__main__":
|
scripts/inference.py
CHANGED
|
@@ -1,66 +1,47 @@
|
|
| 1 |
-
"""
|
| 2 |
|
|
|
|
| 3 |
import importlib.util
|
| 4 |
from pathlib import Path
|
| 5 |
-
|
| 6 |
import numpy as np
|
| 7 |
import torch
|
| 8 |
import yaml
|
| 9 |
|
| 10 |
-
|
| 11 |
ROOT = Path(__file__).resolve().parents[1]
|
| 12 |
|
| 13 |
|
| 14 |
-
def load_model_class():
|
| 15 |
-
spec = importlib.util.spec_from_file_location("remoteclip_model", ROOT / "model" / "remoteclip.py")
|
| 16 |
-
module = importlib.util.module_from_spec(spec)
|
| 17 |
-
spec.loader.exec_module(module)
|
| 18 |
-
return module.RemoteCLIP
|
| 19 |
-
|
| 20 |
-
|
| 21 |
def main():
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
if not
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
checkpoint = torch.load(checkpoint_path, map_location=
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
context_length=config["data"]["context_length"],
|
| 36 |
-
**config["model"],
|
| 37 |
-
).to(device)
|
| 38 |
model.load_state_dict(checkpoint["model"])
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
| 43 |
with torch.inference_mode():
|
| 44 |
-
image_features = model.encode_image(images)
|
| 45 |
-
text_features = model.encode_text(tokens)
|
| 46 |
-
|
| 47 |
-
output_dir
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
data_source=archive["data_source"],
|
| 57 |
-
protocol=archive["protocol"],
|
| 58 |
-
)
|
| 59 |
-
print(
|
| 60 |
-
f"output={output_dir.relative_to(ROOT)} samples={len(images)} "
|
| 61 |
-
f"data_source={str(archive['data_source'])} protocol={str(archive['protocol'])}"
|
| 62 |
-
)
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
if __name__ == "__main__":
|
| 66 |
-
main()
|
|
|
|
| 1 |
+
"""Run checkpoint-backed RemoteCLIP retrieval inference."""
|
| 2 |
|
| 3 |
+
import argparse
|
| 4 |
import importlib.util
|
| 5 |
from pathlib import Path
|
|
|
|
| 6 |
import numpy as np
|
| 7 |
import torch
|
| 8 |
import yaml
|
| 9 |
|
|
|
|
| 10 |
ROOT = Path(__file__).resolve().parents[1]
|
| 11 |
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
def main():
|
| 14 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 15 |
+
parser.add_argument("--config", type=Path, default=ROOT / "conf/config.yaml")
|
| 16 |
+
parser.add_argument("--data", type=Path); parser.add_argument("--checkpoint", type=Path)
|
| 17 |
+
parser.add_argument("--output-dir", type=Path); parser.add_argument("--device", choices=("auto", "cpu", "cuda"), default="auto")
|
| 18 |
+
args = parser.parse_args(); config = yaml.safe_load(args.config.read_text())
|
| 19 |
+
checkpoint_path = args.checkpoint or ROOT / config["paths"]["checkpoint"]
|
| 20 |
+
if not checkpoint_path.is_file(): raise FileNotFoundError(f"checkpoint not found: {checkpoint_path}")
|
| 21 |
+
spec = importlib.util.spec_from_file_location("remoteclip", ROOT / "model/remoteclip.py")
|
| 22 |
+
module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
|
| 23 |
+
checkpoint = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
|
| 24 |
+
model = module.RemoteCLIP(vocabulary_size=config["data"]["vocabulary_size"],
|
| 25 |
+
context_length=config["data"]["context_length"],
|
| 26 |
+
eot_token_id=config["data"]["eot_token_id"], **config["model"])
|
|
|
|
|
|
|
|
|
|
| 27 |
model.load_state_dict(checkpoint["model"])
|
| 28 |
+
use_cuda = torch.cuda.is_available() and args.device != "cpu"
|
| 29 |
+
if args.device == "cuda" and not use_cuda: raise RuntimeError("CUDA requested but unavailable")
|
| 30 |
+
device = torch.device("cuda" if use_cuda else "cpu"); model.to(device).eval()
|
| 31 |
+
data_path = args.data or ROOT / config["data"]["root"] / "test.npz"
|
| 32 |
+
train_spec = importlib.util.spec_from_file_location("remoteclip_train", ROOT / "scripts/train.py")
|
| 33 |
+
train_module = importlib.util.module_from_spec(train_spec); train_spec.loader.exec_module(train_module)
|
| 34 |
+
dataset = train_module.PairDataset(data_path, config); archive = np.load(data_path)
|
| 35 |
with torch.inference_mode():
|
| 36 |
+
image_features = model.encode_image(torch.from_numpy(archive["images"]).to(device))
|
| 37 |
+
text_features = model.encode_text(torch.from_numpy(archive["tokens"]).to(device))
|
| 38 |
+
output_dir = args.output_dir or ROOT / config["paths"]["inference_dir"]; output_dir.mkdir(parents=True, exist_ok=True)
|
| 39 |
+
np.savez_compressed(output_dir / "retrieval.npz", similarities=(image_features @ text_features.T).cpu().numpy(),
|
| 40 |
+
image_features=image_features.cpu().numpy(), text_features=text_features.cpu().numpy(),
|
| 41 |
+
pair_ids=archive["pair_ids"], images=archive["images"], checkpoint=np.asarray(str(checkpoint_path)),
|
| 42 |
+
data_source=archive["data_source"] if "data_source" in archive else np.asarray("provided"),
|
| 43 |
+
protocol=archive["protocol"] if "protocol" in archive else np.asarray("provided_npz"))
|
| 44 |
+
print(f"inference={output_dir / 'retrieval.npz'} checkpoint={checkpoint_path}")
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
if __name__ == "__main__": main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/result.py
CHANGED
|
@@ -1,69 +1,41 @@
|
|
| 1 |
-
"""Evaluate bidirectional retrieval and
|
| 2 |
|
|
|
|
| 3 |
import json
|
| 4 |
from pathlib import Path
|
| 5 |
-
|
| 6 |
import matplotlib.pyplot as plt
|
| 7 |
import numpy as np
|
| 8 |
import yaml
|
| 9 |
|
| 10 |
-
|
| 11 |
ROOT = Path(__file__).resolve().parents[1]
|
| 12 |
|
| 13 |
|
| 14 |
-
def recall(
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
targets = np.arange(len(scores))[:, None]
|
| 18 |
-
return float((topk == targets).any(axis=1).mean())
|
| 19 |
|
| 20 |
|
| 21 |
def main():
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
"
|
| 32 |
-
"
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
metrics["text_to_image_r1"],
|
| 46 |
-
metrics["text_to_image_r5"],
|
| 47 |
-
]
|
| 48 |
-
)
|
| 49 |
-
)
|
| 50 |
-
output_dir = ROOT / config["paths"]["evaluation_dir"]
|
| 51 |
-
output_dir.mkdir(parents=True, exist_ok=True)
|
| 52 |
-
(output_dir / "metrics.json").write_text(
|
| 53 |
-
json.dumps(metrics, indent=2) + "\n", encoding="utf-8"
|
| 54 |
-
)
|
| 55 |
-
figure, axis = plt.subplots(figsize=(5, 4))
|
| 56 |
-
image = axis.imshow(similarities, cmap="viridis")
|
| 57 |
-
axis.set_xlabel("Text index")
|
| 58 |
-
axis.set_ylabel("Image index")
|
| 59 |
-
axis.set_title("RemoteCLIP image-text similarity")
|
| 60 |
-
figure.colorbar(image, ax=axis)
|
| 61 |
-
figure.tight_layout()
|
| 62 |
-
figure.savefig(output_dir / "similarity_matrix.png", dpi=120)
|
| 63 |
-
plt.close(figure)
|
| 64 |
-
print(json.dumps(metrics))
|
| 65 |
-
print(f"evaluation={output_dir.relative_to(ROOT)}")
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
if __name__ == "__main__":
|
| 69 |
-
main()
|
|
|
|
| 1 |
+
"""Evaluate multi-positive bidirectional retrieval and visualize similarities."""
|
| 2 |
|
| 3 |
+
import argparse
|
| 4 |
import json
|
| 5 |
from pathlib import Path
|
|
|
|
| 6 |
import matplotlib.pyplot as plt
|
| 7 |
import numpy as np
|
| 8 |
import yaml
|
| 9 |
|
|
|
|
| 10 |
ROOT = Path(__file__).resolve().parents[1]
|
| 11 |
|
| 12 |
|
| 13 |
+
def recall(scores, query_ids, candidate_ids, k):
|
| 14 |
+
top = np.argsort(-scores, axis=1)[:, :min(k, scores.shape[1])]
|
| 15 |
+
return float(np.mean([np.isin(candidate_ids[index], query_ids[row]).any() for row, index in enumerate(top)]))
|
|
|
|
|
|
|
| 16 |
|
| 17 |
|
| 18 |
def main():
|
| 19 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 20 |
+
parser.add_argument("--config", type=Path, default=ROOT / "conf/config.yaml")
|
| 21 |
+
parser.add_argument("--input", type=Path); parser.add_argument("--output-dir", type=Path)
|
| 22 |
+
args = parser.parse_args(); config = yaml.safe_load(args.config.read_text())
|
| 23 |
+
source = args.input or ROOT / config["paths"]["inference_dir"] / "retrieval.npz"
|
| 24 |
+
if not source.is_file(): raise FileNotFoundError("Run inference before evaluation")
|
| 25 |
+
archive = np.load(source); scores, ids = archive["similarities"], archive["pair_ids"]
|
| 26 |
+
metrics = {}
|
| 27 |
+
for k in (1, 5, 10):
|
| 28 |
+
metrics[f"image_to_text_R@{k}"] = recall(scores, ids, ids, k)
|
| 29 |
+
metrics[f"text_to_image_R@{k}"] = recall(scores.T, ids, ids, k)
|
| 30 |
+
metrics["mean_recall"] = float(np.mean(list(metrics.values())))
|
| 31 |
+
metrics.update(samples=int(len(ids)), protocol=str(archive["protocol"]), checkpoint=str(archive["checkpoint"]),
|
| 32 |
+
multi_positive=True, data_source=str(archive["data_source"]))
|
| 33 |
+
output = args.output_dir or ROOT / config["paths"]["evaluation_dir"]; output.mkdir(parents=True, exist_ok=True)
|
| 34 |
+
(output / "metrics.json").write_text(json.dumps(metrics, indent=2) + "\n")
|
| 35 |
+
figure, axis = plt.subplots(figsize=(5.4, 4.5)); image = axis.imshow(scores, cmap="magma")
|
| 36 |
+
axis.set(xlabel="Text candidate", ylabel="Image query", title="RemoteCLIP cosine similarity")
|
| 37 |
+
figure.colorbar(image, ax=axis); figure.tight_layout(); figure.savefig(output / "similarity_matrix.png", dpi=160); plt.close(figure)
|
| 38 |
+
print(json.dumps(metrics, indent=2)); print(f"evaluation={output}")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
if __name__ == "__main__": main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/train.py
CHANGED
|
@@ -1,132 +1,158 @@
|
|
| 1 |
-
"""Train
|
| 2 |
|
|
|
|
| 3 |
import importlib.util
|
| 4 |
import json
|
| 5 |
import os
|
| 6 |
-
import
|
|
|
|
| 7 |
from pathlib import Path
|
| 8 |
|
| 9 |
import numpy as np
|
| 10 |
import torch
|
| 11 |
import yaml
|
| 12 |
from torch import distributed as dist
|
|
|
|
| 13 |
from torch.nn.parallel import DistributedDataParallel
|
| 14 |
from torch.utils.data import DataLoader, Dataset, DistributedSampler
|
| 15 |
|
| 16 |
-
|
| 17 |
ROOT = Path(__file__).resolve().parents[1]
|
| 18 |
|
| 19 |
|
| 20 |
-
def
|
| 21 |
-
spec = importlib.util.spec_from_file_location("
|
| 22 |
module = importlib.util.module_from_spec(spec)
|
| 23 |
spec.loader.exec_module(module)
|
| 24 |
-
return module
|
| 25 |
|
| 26 |
|
| 27 |
class PairDataset(Dataset):
|
| 28 |
-
def __init__(self, path):
|
| 29 |
archive = np.load(path)
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
self.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
-
def __len__(self):
|
| 36 |
-
return len(self.images)
|
| 37 |
|
| 38 |
def __getitem__(self, index):
|
| 39 |
-
return torch.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
|
| 42 |
def main():
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
if world_size > 1:
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
np.random.seed(seed)
|
| 61 |
-
torch.manual_seed(seed)
|
| 62 |
-
dataset = PairDataset(data_path)
|
| 63 |
-
sampler = DistributedSampler(dataset, shuffle=True) if world_size > 1 else None
|
| 64 |
-
loader = DataLoader(
|
| 65 |
-
dataset,
|
| 66 |
-
batch_size=config["train"]["batch_size"],
|
| 67 |
-
shuffle=sampler is None,
|
| 68 |
-
sampler=sampler,
|
| 69 |
-
num_workers=config["train"]["num_workers"],
|
| 70 |
-
)
|
| 71 |
-
RemoteCLIP = load_model_class()
|
| 72 |
-
model = RemoteCLIP(
|
| 73 |
-
vocabulary_size=config["data"]["vocabulary_size"],
|
| 74 |
-
context_length=config["data"]["context_length"],
|
| 75 |
-
**config["model"],
|
| 76 |
-
).to(device)
|
| 77 |
-
if world_size > 1:
|
| 78 |
-
model = DistributedDataParallel(model, device_ids=[local_rank] if device.type == "cuda" else None)
|
| 79 |
-
optimizer = torch.optim.AdamW(
|
| 80 |
-
model.parameters(),
|
| 81 |
-
lr=config["train"]["learning_rate"],
|
| 82 |
-
weight_decay=config["train"]["weight_decay"],
|
| 83 |
-
)
|
| 84 |
history = []
|
| 85 |
-
for epoch in range(config["
|
| 86 |
-
if sampler is not None:
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
torch.save(
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
metrics.write_text(
|
| 116 |
-
json.dumps(
|
| 117 |
-
{"history": history, "data_source": dataset.data_source, "protocol": dataset.protocol},
|
| 118 |
-
indent=2,
|
| 119 |
-
)
|
| 120 |
-
+ "\n",
|
| 121 |
-
encoding="utf-8",
|
| 122 |
-
)
|
| 123 |
-
print(
|
| 124 |
-
f"checkpoint={checkpoint.relative_to(ROOT)} data_source={dataset.data_source} "
|
| 125 |
-
f"protocol={dataset.protocol}"
|
| 126 |
-
)
|
| 127 |
-
if world_size > 1:
|
| 128 |
-
dist.destroy_process_group()
|
| 129 |
|
| 130 |
|
| 131 |
-
if __name__ == "__main__":
|
| 132 |
-
main()
|
|
|
|
| 1 |
+
"""Train RemoteCLIP with multi-positive symmetric contrastive loss and torchrun."""
|
| 2 |
|
| 3 |
+
import argparse
|
| 4 |
import importlib.util
|
| 5 |
import json
|
| 6 |
import os
|
| 7 |
+
from contextlib import nullcontext
|
| 8 |
+
from functools import partial
|
| 9 |
from pathlib import Path
|
| 10 |
|
| 11 |
import numpy as np
|
| 12 |
import torch
|
| 13 |
import yaml
|
| 14 |
from torch import distributed as dist
|
| 15 |
+
from torch.distributed.nn import functional as dist_nn
|
| 16 |
from torch.nn.parallel import DistributedDataParallel
|
| 17 |
from torch.utils.data import DataLoader, Dataset, DistributedSampler
|
| 18 |
|
|
|
|
| 19 |
ROOT = Path(__file__).resolve().parents[1]
|
| 20 |
|
| 21 |
|
| 22 |
+
def load_model_module():
|
| 23 |
+
spec = importlib.util.spec_from_file_location("remoteclip", ROOT / "model/remoteclip.py")
|
| 24 |
module = importlib.util.module_from_spec(spec)
|
| 25 |
spec.loader.exec_module(module)
|
| 26 |
+
return module
|
| 27 |
|
| 28 |
|
| 29 |
class PairDataset(Dataset):
|
| 30 |
+
def __init__(self, path, config):
|
| 31 |
archive = np.load(path)
|
| 32 |
+
required = {"images", "tokens", "pair_ids"}
|
| 33 |
+
if not required.issubset(archive.files):
|
| 34 |
+
raise ValueError(f"NPZ requires {sorted(required)}")
|
| 35 |
+
self.images, self.tokens, self.pair_ids = (archive[key] for key in ("images", "tokens", "pair_ids"))
|
| 36 |
+
self.data_source = str(archive["data_source"]) if "data_source" in archive else "provided"
|
| 37 |
+
self.protocol = str(archive["protocol"]) if "protocol" in archive else "provided_npz"
|
| 38 |
+
if self.protocol != config["data"]["protocol"]:
|
| 39 |
+
raise ValueError("NPZ protocol does not match configuration")
|
| 40 |
+
if self.images.ndim != 4 or self.images.shape[1:] != (3, 224, 224) or self.images.dtype != np.float32:
|
| 41 |
+
raise ValueError("images must be float32 [N,3,224,224]")
|
| 42 |
+
if self.tokens.ndim != 2 or self.tokens.shape[1:] != (77,) or self.tokens.dtype != np.int64:
|
| 43 |
+
raise ValueError("tokens must be int64 [N,77]")
|
| 44 |
+
if self.pair_ids.ndim != 1 or self.pair_ids.dtype != np.int64:
|
| 45 |
+
raise ValueError("pair_ids must be int64 [N]")
|
| 46 |
+
if not (len(self.images) == len(self.tokens) == len(self.pair_ids)) or len(self.images) == 0:
|
| 47 |
+
raise ValueError("images, tokens, and pair_ids must have the same non-zero sample count")
|
| 48 |
+
vocabulary_size = config["data"]["vocabulary_size"]
|
| 49 |
+
if self.tokens.min() < 0 or self.tokens.max() >= vocabulary_size:
|
| 50 |
+
raise ValueError(f"token ids must be in [0,{vocabulary_size})")
|
| 51 |
+
sot, eot, pad = (config["data"][key] for key in ("sot_token_id", "eot_token_id", "pad_token_id"))
|
| 52 |
+
if not np.all(self.tokens[:, 0] == sot):
|
| 53 |
+
raise ValueError("standard CLIP sequences must start with SOT")
|
| 54 |
+
eot_mask = self.tokens == eot
|
| 55 |
+
if not np.all(eot_mask.sum(axis=1) == 1):
|
| 56 |
+
raise ValueError("each token sequence must contain exactly one EOT")
|
| 57 |
+
eot_positions = eot_mask.argmax(axis=1)
|
| 58 |
+
for row, position in zip(self.tokens, eot_positions):
|
| 59 |
+
if np.any(row[1:position] == pad) or np.any(row[position + 1:] != pad):
|
| 60 |
+
raise ValueError("tokens before EOT must be non-padding and all tokens after EOT must be padding")
|
| 61 |
+
if not (0 <= pad < sot < eot < vocabulary_size):
|
| 62 |
+
raise ValueError("CLIP token configuration must satisfy pad < SOT < EOT < vocabulary_size")
|
| 63 |
|
| 64 |
+
def __len__(self): return len(self.images)
|
|
|
|
| 65 |
|
| 66 |
def __getitem__(self, index):
|
| 67 |
+
return tuple(torch.as_tensor(array[index]) for array in (self.images, self.tokens, self.pair_ids))
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def gather_with_local_grad(features):
|
| 71 |
+
if not dist.is_initialized():
|
| 72 |
+
return features
|
| 73 |
+
# Autograd all_gather uses reduce-scatter in backward. Combined with DDP's
|
| 74 |
+
# parameter-gradient averaging, this is the exact gradient of one global loss.
|
| 75 |
+
return torch.cat(dist_nn.all_gather(features), dim=0)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def gather_ids(pair_ids):
|
| 79 |
+
if not dist.is_initialized():
|
| 80 |
+
return pair_ids
|
| 81 |
+
gathered = [torch.zeros_like(pair_ids) for _ in range(dist.get_world_size())]
|
| 82 |
+
dist.all_gather(gathered, pair_ids)
|
| 83 |
+
return torch.cat(gathered)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def model_kwargs(config):
|
| 87 |
+
return {"vocabulary_size": config["data"]["vocabulary_size"],
|
| 88 |
+
"context_length": config["data"]["context_length"],
|
| 89 |
+
"eot_token_id": config["data"]["eot_token_id"], **config["model"]}
|
| 90 |
|
| 91 |
|
| 92 |
def main():
|
| 93 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 94 |
+
parser.add_argument("--config", type=Path, default=ROOT / "conf/config.yaml")
|
| 95 |
+
parser.add_argument("--data", type=Path)
|
| 96 |
+
parser.add_argument("--checkpoint", type=Path)
|
| 97 |
+
parser.add_argument("--device", choices=("auto", "cpu", "cuda"))
|
| 98 |
+
args = parser.parse_args()
|
| 99 |
+
config = yaml.safe_load(args.config.read_text())
|
| 100 |
+
world_size, rank = int(os.environ.get("WORLD_SIZE", 1)), int(os.environ.get("RANK", 0))
|
| 101 |
+
local_rank = int(os.environ.get("LOCAL_RANK", 0))
|
| 102 |
+
requested = args.device or config["runtime"]["device"]
|
| 103 |
+
use_cuda = torch.cuda.is_available() and requested != "cpu"
|
| 104 |
+
if requested == "cuda" and not use_cuda: raise RuntimeError("CUDA requested but unavailable")
|
| 105 |
+
if world_size > 1: dist.init_process_group("nccl" if use_cuda else "gloo")
|
| 106 |
+
device = torch.device(f"cuda:{local_rank}" if use_cuda else "cpu")
|
| 107 |
+
if use_cuda: torch.cuda.set_device(local_rank)
|
| 108 |
+
torch.manual_seed(config["seed"] + rank)
|
| 109 |
+
data_path = args.data or ROOT / config["data"]["root"] / "train.npz"
|
| 110 |
+
dataset = PairDataset(data_path, config)
|
| 111 |
+
sampler = DistributedSampler(dataset, shuffle=True, drop_last=True) if world_size > 1 else None
|
| 112 |
+
loader = DataLoader(dataset, batch_size=config["training"]["batch_size"], sampler=sampler,
|
| 113 |
+
shuffle=sampler is None, num_workers=config["training"]["num_workers"],
|
| 114 |
+
drop_last=world_size > 1)
|
| 115 |
+
module = load_model_module()
|
| 116 |
+
model = module.RemoteCLIP(**model_kwargs(config)).to(device)
|
| 117 |
+
raw_model = model
|
| 118 |
if world_size > 1:
|
| 119 |
+
model = DistributedDataParallel(model, device_ids=[local_rank] if use_cuda else None)
|
| 120 |
+
raw_model = model.module
|
| 121 |
+
optimizer = torch.optim.AdamW(model.parameters(), lr=config["training"]["learning_rate"],
|
| 122 |
+
weight_decay=config["training"]["weight_decay"])
|
| 123 |
+
amp = bool(config["runtime"]["amp"] and use_cuda)
|
| 124 |
+
scaler = torch.amp.GradScaler("cuda", enabled=amp)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
history = []
|
| 126 |
+
for epoch in range(config["training"]["epochs"]):
|
| 127 |
+
if sampler is not None: sampler.set_epoch(epoch)
|
| 128 |
+
model.train(); total = 0.0
|
| 129 |
+
for images, tokens, pair_ids in loader:
|
| 130 |
+
images, tokens, pair_ids = images.to(device), tokens.to(device), pair_ids.to(device)
|
| 131 |
+
autocast = partial(torch.amp.autocast, "cuda") if amp else nullcontext
|
| 132 |
+
with autocast():
|
| 133 |
+
image_features, text_features, scale = model(images, tokens)
|
| 134 |
+
loss = module.multi_positive_clip_loss(gather_with_local_grad(image_features),
|
| 135 |
+
gather_with_local_grad(text_features),
|
| 136 |
+
gather_ids(pair_ids), scale)
|
| 137 |
+
optimizer.zero_grad(set_to_none=True); scaler.scale(loss).backward()
|
| 138 |
+
scaler.step(optimizer); scaler.update(); total += loss.detach().item()
|
| 139 |
+
statistics = torch.tensor([total, len(loader)], dtype=torch.float64, device=device)
|
| 140 |
+
if world_size > 1: dist.all_reduce(statistics, op=dist.ReduceOp.SUM)
|
| 141 |
+
record = {"epoch": epoch + 1, "contrastive_loss": statistics[0].item() / max(statistics[1].item(), 1)}
|
| 142 |
+
history.append(record)
|
| 143 |
+
if rank == 0: print(f"epoch={record['epoch']} contrastive_loss={record['contrastive_loss']:.6f}")
|
| 144 |
+
if rank == 0:
|
| 145 |
+
checkpoint_path = args.checkpoint or ROOT / config["paths"]["checkpoint"]
|
| 146 |
+
checkpoint_path.parent.mkdir(parents=True, exist_ok=True)
|
| 147 |
+
torch.save({"model": raw_model.state_dict(), "optimizer": optimizer.state_dict(),
|
| 148 |
+
"scaler": scaler.state_dict() if amp else None, "config": config,
|
| 149 |
+
"epoch": config["training"]["epochs"], "history": history}, checkpoint_path)
|
| 150 |
+
metrics = ROOT / config["paths"]["training_metrics"]; metrics.parent.mkdir(parents=True, exist_ok=True)
|
| 151 |
+
metrics.write_text(json.dumps({"history": history, "protocol": dataset.protocol,
|
| 152 |
+
"data_source": dataset.data_source,
|
| 153 |
+
"world_size": world_size, "amp": amp}, indent=2) + "\n")
|
| 154 |
+
print(f"checkpoint={checkpoint_path}")
|
| 155 |
+
if world_size > 1: dist.destroy_process_group()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
|
| 157 |
|
| 158 |
+
if __name__ == "__main__": main()
|
|
|