Download scripts/train.py from OneScience-Group/RemoteCLIP: direct link, hf CLI and curl.
- Browser
- Download file 8.53 kB
-
https://huggingface.co/OneScience-Group/RemoteCLIP/resolve/main/scripts/train.py
- Command line
-
hf download hf://OneScience-Group/RemoteCLIP/scripts/train.py
-
curl -L -o train.py https://huggingface.co/OneScience-Group/RemoteCLIP/resolve/main/scripts/train.py
8.53 kB
| """Train RemoteCLIP with multi-positive symmetric contrastive loss and torchrun.""" | |
| import argparse | |
| import importlib.util | |
| import json | |
| import os | |
| from contextlib import nullcontext | |
| from functools import partial | |
| from pathlib import Path | |
| import numpy as np | |
| import torch | |
| import yaml | |
| from torch import distributed as dist | |
| from torch.distributed.nn import functional as dist_nn | |
| from torch.nn.parallel import DistributedDataParallel | |
| from torch.utils.data import DataLoader, Dataset, DistributedSampler | |
| ROOT = Path(__file__).resolve().parents[1] | |
| def load_model_module(): | |
| spec = importlib.util.spec_from_file_location("remoteclip", ROOT / "model/remoteclip.py") | |
| module = importlib.util.module_from_spec(spec) | |
| spec.loader.exec_module(module) | |
| return module | |
| class PairDataset(Dataset): | |
| def __init__(self, path, config): | |
| archive = np.load(path) | |
| required = {"images", "tokens", "pair_ids"} | |
| if not required.issubset(archive.files): | |
| raise ValueError(f"NPZ requires {sorted(required)}") | |
| self.images, self.tokens, self.pair_ids = (archive[key] for key in ("images", "tokens", "pair_ids")) | |
| self.data_source = str(archive["data_source"]) if "data_source" in archive else "provided" | |
| self.protocol = str(archive["protocol"]) if "protocol" in archive else "provided_npz" | |
| if self.protocol != config["data"]["protocol"]: | |
| raise ValueError("NPZ protocol does not match configuration") | |
| if self.images.ndim != 4 or self.images.shape[1:] != (3, 224, 224) or self.images.dtype != np.float32: | |
| raise ValueError("images must be float32 [N,3,224,224]") | |
| if self.tokens.ndim != 2 or self.tokens.shape[1:] != (77,) or self.tokens.dtype != np.int64: | |
| raise ValueError("tokens must be int64 [N,77]") | |
| if self.pair_ids.ndim != 1 or self.pair_ids.dtype != np.int64: | |
| raise ValueError("pair_ids must be int64 [N]") | |
| if not (len(self.images) == len(self.tokens) == len(self.pair_ids)) or len(self.images) == 0: | |
| raise ValueError("images, tokens, and pair_ids must have the same non-zero sample count") | |
| vocabulary_size = config["data"]["vocabulary_size"] | |
| if self.tokens.min() < 0 or self.tokens.max() >= vocabulary_size: | |
| raise ValueError(f"token ids must be in [0,{vocabulary_size})") | |
| sot, eot, pad = (config["data"][key] for key in ("sot_token_id", "eot_token_id", "pad_token_id")) | |
| if not np.all(self.tokens[:, 0] == sot): | |
| raise ValueError("standard CLIP sequences must start with SOT") | |
| eot_mask = self.tokens == eot | |
| if not np.all(eot_mask.sum(axis=1) == 1): | |
| raise ValueError("each token sequence must contain exactly one EOT") | |
| eot_positions = eot_mask.argmax(axis=1) | |
| for row, position in zip(self.tokens, eot_positions): | |
| if np.any(row[1:position] == pad) or np.any(row[position + 1:] != pad): | |
| raise ValueError("tokens before EOT must be non-padding and all tokens after EOT must be padding") | |
| if not (0 <= pad < sot < eot < vocabulary_size): | |
| raise ValueError("CLIP token configuration must satisfy pad < SOT < EOT < vocabulary_size") | |
| def __len__(self): return len(self.images) | |
| def __getitem__(self, index): | |
| return tuple(torch.as_tensor(array[index]) for array in (self.images, self.tokens, self.pair_ids)) | |
| def gather_with_local_grad(features): | |
| if not dist.is_initialized(): | |
| return features | |
| # Autograd all_gather uses reduce-scatter in backward. Combined with DDP's | |
| # parameter-gradient averaging, this is the exact gradient of one global loss. | |
| return torch.cat(dist_nn.all_gather(features), dim=0) | |
| def gather_ids(pair_ids): | |
| if not dist.is_initialized(): | |
| return pair_ids | |
| gathered = [torch.zeros_like(pair_ids) for _ in range(dist.get_world_size())] | |
| dist.all_gather(gathered, pair_ids) | |
| return torch.cat(gathered) | |
| def model_kwargs(config): | |
| return {"vocabulary_size": config["data"]["vocabulary_size"], | |
| "context_length": config["data"]["context_length"], | |
| "eot_token_id": config["data"]["eot_token_id"], **config["model"]} | |
| def main(): | |
| parser = argparse.ArgumentParser(description=__doc__) | |
| parser.add_argument("--config", type=Path, default=ROOT / "conf/config.yaml") | |
| parser.add_argument("--data", type=Path) | |
| parser.add_argument("--checkpoint", type=Path) | |
| parser.add_argument("--device", choices=("auto", "cpu", "cuda")) | |
| args = parser.parse_args() | |
| config = yaml.safe_load(args.config.read_text()) | |
| world_size, rank = int(os.environ.get("WORLD_SIZE", 1)), int(os.environ.get("RANK", 0)) | |
| local_rank = int(os.environ.get("LOCAL_RANK", 0)) | |
| requested = args.device or config["runtime"]["device"] | |
| use_cuda = torch.cuda.is_available() and requested != "cpu" | |
| if requested == "cuda" and not use_cuda: raise RuntimeError("CUDA requested but unavailable") | |
| if world_size > 1: dist.init_process_group("nccl" if use_cuda else "gloo") | |
| device = torch.device(f"cuda:{local_rank}" if use_cuda else "cpu") | |
| if use_cuda: torch.cuda.set_device(local_rank) | |
| torch.manual_seed(config["seed"] + rank) | |
| data_path = args.data or ROOT / config["data"]["root"] / "train.npz" | |
| dataset = PairDataset(data_path, config) | |
| sampler = DistributedSampler(dataset, shuffle=True, drop_last=True) if world_size > 1 else None | |
| loader = DataLoader(dataset, batch_size=config["training"]["batch_size"], sampler=sampler, | |
| shuffle=sampler is None, num_workers=config["training"]["num_workers"], | |
| drop_last=world_size > 1) | |
| module = load_model_module() | |
| model = module.RemoteCLIP(**model_kwargs(config)).to(device) | |
| raw_model = model | |
| if world_size > 1: | |
| model = DistributedDataParallel(model, device_ids=[local_rank] if use_cuda else None) | |
| raw_model = model.module | |
| optimizer = torch.optim.AdamW(model.parameters(), lr=config["training"]["learning_rate"], | |
| weight_decay=config["training"]["weight_decay"]) | |
| amp = bool(config["runtime"]["amp"] and use_cuda) | |
| scaler = torch.amp.GradScaler("cuda", enabled=amp) | |
| history = [] | |
| for epoch in range(config["training"]["epochs"]): | |
| if sampler is not None: sampler.set_epoch(epoch) | |
| model.train(); total = 0.0 | |
| for images, tokens, pair_ids in loader: | |
| images, tokens, pair_ids = images.to(device), tokens.to(device), pair_ids.to(device) | |
| autocast = partial(torch.amp.autocast, "cuda") if amp else nullcontext | |
| with autocast(): | |
| image_features, text_features, scale = model(images, tokens) | |
| loss = module.multi_positive_clip_loss(gather_with_local_grad(image_features), | |
| gather_with_local_grad(text_features), | |
| gather_ids(pair_ids), scale) | |
| optimizer.zero_grad(set_to_none=True); scaler.scale(loss).backward() | |
| scaler.step(optimizer); scaler.update(); total += loss.detach().item() | |
| statistics = torch.tensor([total, len(loader)], dtype=torch.float64, device=device) | |
| if world_size > 1: dist.all_reduce(statistics, op=dist.ReduceOp.SUM) | |
| record = {"epoch": epoch + 1, "contrastive_loss": statistics[0].item() / max(statistics[1].item(), 1)} | |
| history.append(record) | |
| if rank == 0: print(f"epoch={record['epoch']} contrastive_loss={record['contrastive_loss']:.6f}") | |
| if rank == 0: | |
| checkpoint_path = args.checkpoint or ROOT / config["paths"]["checkpoint"] | |
| checkpoint_path.parent.mkdir(parents=True, exist_ok=True) | |
| torch.save({"model": raw_model.state_dict(), "optimizer": optimizer.state_dict(), | |
| "scaler": scaler.state_dict() if amp else None, "config": config, | |
| "epoch": config["training"]["epochs"], "history": history}, checkpoint_path) | |
| metrics = ROOT / config["paths"]["training_metrics"]; metrics.parent.mkdir(parents=True, exist_ok=True) | |
| metrics.write_text(json.dumps({"history": history, "protocol": dataset.protocol, | |
| "data_source": dataset.data_source, | |
| "world_size": world_size, "amp": amp}, indent=2) + "\n") | |
| print(f"checkpoint={checkpoint_path}") | |
| if world_size > 1: dist.destroy_process_group() | |
| if __name__ == "__main__": main() | |