zhangrenchao commited on
Commit
2ca760b
·
verified ·
1 Parent(s): 7002f4e

Update RemoteCLIP model package

Browse files
conf/config.yaml CHANGED
@@ -1,25 +1,36 @@
1
  seed: 42
2
  data:
3
- path: data/remoteclip.npz
4
- protocol: synthetic_remoteclip
5
- train_samples: 32
6
- test_samples: 8
7
- image_size: 32
8
- vocabulary_size: 64
9
- context_length: 8
10
- num_classes: 4
 
 
 
 
11
  model:
12
- embed_dim: 32
13
- vision_width: 32
14
- text_width: 32
 
 
 
 
15
  text_layers: 2
16
  text_heads: 4
17
- train:
18
- epochs: 5
19
- batch_size: 8
20
- learning_rate: 0.001
21
- weight_decay: 0.0001
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-science",
9
  "task": "remote-sensing-image-text-retrieval",
10
  "implementation": {
11
  "entry_point": "model/remoteclip.py",
12
- "scope": "compact image-text contrastive RemoteCLIP reproduction"
13
  },
14
  "architecture": {
15
- "family": "dual-encoder vision-language model",
16
- "input_format": "image-text NPZ pairs",
17
- "image_channels": 3,
18
- "image_size": 32,
19
- "vocabulary_size": 64,
20
- "context_length": 8,
21
- "embed_dim": 32,
22
- "vision_width": 32,
23
- "text_width": 32,
 
 
 
 
 
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
- "official_repo": "ChenDelong1999/RemoteCLIP",
35
- "protocol": "synthetic_remoteclip",
36
- "synthetic_train_samples": 32,
37
- "synthetic_test_samples": 8
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": "synthetic_remoteclip",
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
- """Compact RemoteCLIP image-text contrastive model."""
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=64,
14
- context_length=8,
15
- embed_dim=32,
16
- vision_width=32,
17
- text_width=32,
 
 
 
 
 
18
  text_layers=2,
19
  text_heads=4,
20
  ):
21
  super().__init__()
22
  self.context_length = context_length
23
- self.image_encoder = nn.Sequential(
24
- nn.Conv2d(3, vision_width, 5, stride=2, padding=2),
25
- nn.GELU(),
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.zeros(1, context_length, text_width))
34
- layer = nn.TransformerEncoderLayer(
35
- text_width,
36
- text_heads,
37
- text_width * 4,
38
- dropout=0.0,
39
- activation="gelu",
40
- batch_first=True,
41
- norm_first=True,
42
  )
43
- self.text_encoder = nn.TransformerEncoder(layer, text_layers)
44
- self.text_projection = nn.Linear(text_width, embed_dim)
 
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
- return F.normalize(self.image_encoder(images), dim=-1)
 
 
50
 
51
  def encode_text(self, tokens):
52
- features = self.text_encoder(self.token_embedding(tokens) + self.position_embedding)
53
- mask = tokens.ne(0).unsqueeze(-1)
54
- pooled = (features * mask).sum(1) / mask.sum(1).clamp_min(1)
55
- return F.normalize(self.text_projection(pooled), dim=-1)
 
 
 
 
 
 
56
 
57
  def forward(self, images, tokens):
58
- image_features = self.encode_image(images)
59
- text_features = self.encode_text(tokens)
60
- scale = self.logit_scale.exp().clamp(max=100)
61
- logits = scale * image_features @ text_features.t()
62
- targets = torch.arange(logits.shape[0], device=logits.device)
63
- loss = (
64
- F.cross_entropy(logits, targets)
65
- + F.cross_entropy(logits.t(), targets)
66
- ) / 2
67
- return {
68
- "loss": loss,
69
- "logits_per_image": logits,
70
- "image_features": image_features,
71
- "text_features": text_features,
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 deterministic RemoteCLIP-format image-text pairs."""
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
- data = config["data"]
15
- size = data["image_size"]
16
- classes = data["num_classes"]
17
- images = np.empty((count, 3, size, size), dtype=np.float32)
18
- tokens = np.zeros((count, data["context_length"]), dtype=np.int64)
19
- labels = np.arange(count, dtype=np.int64) % classes
20
- y, x = np.mgrid[0:size, 0:size].astype(np.float32) / max(size - 1, 1)
21
- for index, label in enumerate(labels):
22
- image = np.zeros((3, size, size), dtype=np.float32)
23
- image[label % 3] = 0.55 + 0.35 * np.sin((label + 1) * np.pi * x)
24
- image[(label + 1) % 3] += 0.25 * np.cos((label + 1) * np.pi * y)
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
- with (ROOT / "conf" / "config.yaml").open(encoding="utf-8") as handle:
32
- config = yaml.safe_load(handle)
33
- train = make_split(config["data"]["train_samples"], config, config["seed"])
34
- test = make_split(config["data"]["test_samples"], config, config["seed"] + 1)
35
- output = ROOT / config["data"]["path"]
36
- output.parent.mkdir(parents=True, exist_ok=True)
37
- np.savez_compressed(
38
- output,
39
- train_images=train[0],
40
- train_tokens=train[1],
41
- train_labels=train[2],
42
- test_images=test[0],
43
- test_tokens=test[1],
44
- test_labels=test[2],
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
- """Extract RemoteCLIP features and image-text similarity scores."""
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
- with (ROOT / "conf" / "config.yaml").open(encoding="utf-8") as handle:
23
- config = yaml.safe_load(handle)
24
- checkpoint_path = ROOT / config["paths"]["checkpoint"]
25
- data_path = ROOT / config["data"]["path"]
26
- if not checkpoint_path.exists():
27
- raise FileNotFoundError("Missing checkpoint. Run `python scripts/train.py` first.")
28
- if not data_path.exists():
29
- raise FileNotFoundError("Missing data. Run `python scripts/fake_data.py` first.")
30
- device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
31
- checkpoint = torch.load(checkpoint_path, map_location=device, weights_only=False)
32
- RemoteCLIP = load_model_class()
33
- model = RemoteCLIP(
34
- vocabulary_size=config["data"]["vocabulary_size"],
35
- context_length=config["data"]["context_length"],
36
- **config["model"],
37
- ).to(device)
38
  model.load_state_dict(checkpoint["model"])
39
- model.eval()
40
- archive = np.load(data_path)
41
- images = torch.from_numpy(archive["test_images"]).to(device)
42
- tokens = torch.from_numpy(archive["test_tokens"]).to(device)
 
 
 
43
  with torch.inference_mode():
44
- image_features = model.encode_image(images)
45
- text_features = model.encode_text(tokens)
46
- similarities = image_features @ text_features.t()
47
- output_dir = ROOT / config["paths"]["inference_dir"]
48
- output_dir.mkdir(parents=True, exist_ok=True)
49
- np.savez_compressed(
50
- output_dir / "retrieval.npz",
51
- similarities=similarities.cpu().numpy(),
52
- image_features=image_features.cpu().numpy(),
53
- text_features=text_features.cpu().numpy(),
54
- labels=archive["test_labels"],
55
- images=archive["test_images"],
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 render a similarity matrix."""
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(similarities, k, transpose=False):
15
- scores = similarities.T if transpose else similarities
16
- topk = np.argsort(-scores, axis=1)[:, :k]
17
- targets = np.arange(len(scores))[:, None]
18
- return float((topk == targets).any(axis=1).mean())
19
 
20
 
21
  def main():
22
- with (ROOT / "conf" / "config.yaml").open(encoding="utf-8") as handle:
23
- config = yaml.safe_load(handle)
24
- input_path = ROOT / config["paths"]["inference_dir"] / "retrieval.npz"
25
- if not input_path.exists():
26
- raise FileNotFoundError("Missing inference output. Run `python scripts/inference.py` first.")
27
- archive = np.load(input_path)
28
- similarities = archive["similarities"]
29
- limit = len(similarities)
30
- metrics = {
31
- "image_to_text_r1": recall(similarities, 1),
32
- "image_to_text_r5": recall(similarities, min(5, limit)),
33
- "text_to_image_r1": recall(similarities, 1, True),
34
- "text_to_image_r5": recall(similarities, min(5, limit), True),
35
- "mean_recall": 0.0,
36
- "samples": limit,
37
- "data_source": str(archive["data_source"]),
38
- "protocol": str(archive["protocol"]),
39
- }
40
- metrics["mean_recall"] = float(
41
- np.mean(
42
- [
43
- metrics["image_to_text_r1"],
44
- metrics["image_to_text_r5"],
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 compact RemoteCLIP with bidirectional InfoNCE."""
2
 
 
3
  import importlib.util
4
  import json
5
  import os
6
- import random
 
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 load_model_class():
21
- spec = importlib.util.spec_from_file_location("remoteclip_model", ROOT / "model" / "remoteclip.py")
22
  module = importlib.util.module_from_spec(spec)
23
  spec.loader.exec_module(module)
24
- return module.RemoteCLIP
25
 
26
 
27
  class PairDataset(Dataset):
28
- def __init__(self, path):
29
  archive = np.load(path)
30
- self.images = archive["train_images"]
31
- self.tokens = archive["train_tokens"]
32
- self.data_source = str(archive["data_source"]) if "data_source" in archive.files else "unknown"
33
- self.protocol = str(archive["protocol"]) if "protocol" in archive.files else "unknown"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
 
35
- def __len__(self):
36
- return len(self.images)
37
 
38
  def __getitem__(self, index):
39
- return torch.from_numpy(self.images[index]), torch.from_numpy(self.tokens[index])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
 
42
  def main():
43
- with (ROOT / "conf" / "config.yaml").open(encoding="utf-8") as handle:
44
- config = yaml.safe_load(handle)
45
- data_path = ROOT / config["data"]["path"]
46
- if not data_path.exists():
47
- raise FileNotFoundError(
48
- f"Missing training data: {data_path.relative_to(ROOT)}. "
49
- "Run `python scripts/fake_data.py` first."
50
- )
51
- world_size = int(os.environ.get("WORLD_SIZE", "1"))
52
- local_rank = int(os.environ.get("LOCAL_RANK", "0"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  if world_size > 1:
54
- dist.init_process_group("nccl" if torch.cuda.is_available() else "gloo")
55
- device = torch.device(f"cuda:{local_rank}" if torch.cuda.is_available() else "cpu")
56
- if device.type == "cuda":
57
- torch.cuda.set_device(local_rank)
58
- seed = config["seed"] + local_rank
59
- random.seed(seed)
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["train"]["epochs"]):
86
- if sampler is not None:
87
- sampler.set_epoch(epoch)
88
- model.train()
89
- total = 0.0
90
- for images, tokens in loader:
91
- output = model(images.to(device), tokens.to(device))
92
- optimizer.zero_grad(set_to_none=True)
93
- output["loss"].backward()
94
- optimizer.step()
95
- total += output["loss"].item()
96
- loss = total / len(loader)
97
- history.append({"epoch": epoch + 1, "contrastive_loss": loss})
98
- if local_rank == 0:
99
- print(f"epoch={epoch + 1} contrastive_loss={loss:.6f}")
100
- if local_rank == 0:
101
- checkpoint = ROOT / config["paths"]["checkpoint"]
102
- metrics = ROOT / config["paths"]["training_metrics"]
103
- checkpoint.parent.mkdir(parents=True, exist_ok=True)
104
- metrics.parent.mkdir(parents=True, exist_ok=True)
105
- base_model = model.module if hasattr(model, "module") else model
106
- torch.save(
107
- {
108
- "model": base_model.state_dict(),
109
- "config": config,
110
- "data_source": dataset.data_source,
111
- "protocol": dataset.protocol,
112
- },
113
- checkpoint,
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()