elalber2000's picture
first commit
59830d4 verified
Raw
History Blame Contribute Delete
16.1 kB
from __future__ import annotations
import modal
from config import cnf
from embedding_train.image import (
data_volume,
hf_cache_volume,
output_volume,
training_image,
)
app = modal.App(cnf.train_app_name)
@app.function(
image=training_image,
gpu=cnf.train_gpu,
cpu=8,
memory=cnf.train_memory,
timeout=cnf.train_timeout,
volumes={
cnf.train_data_mnt: data_volume.with_mount_options(read_only=True),
cnf.train_output_mnt: output_volume,
cnf.train_hf_cache_mnt: hf_cache_volume,
},
)
def train(
run_name: str = "clip-garments2look-1hour",
max_train_minutes: int = 55,
checkpoint_every_minutes: int = 10,
lr: float = 1e-5,
weight_decay: float = 0.01,
temperature: float = 0.07,
outfits_per_batch: int = 32,
items_per_outfit: int = 2,
num_workers: int = 8,
unfreeze_blocks: int = 2,
seed: int = 42,
resume: bool = True,
) -> str:
import json
import random
import time
from collections import Counter, defaultdict
from pathlib import Path
import torch
import torch.nn.functional as F
from PIL import Image, UnidentifiedImageError
from sentence_transformers import SentenceTransformer
from torch.utils.data import DataLoader, Dataset, Sampler
from tqdm.auto import tqdm
if items_per_outfit < 2:
raise ValueError("items_per_outfit must be at least 2")
if outfits_per_batch < 2:
raise ValueError("outfits_per_batch must be at least 2")
random.seed(seed)
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
torch.backends.cuda.matmul.allow_tf32 = True
torch.backends.cudnn.allow_tf32 = True
data_root = Path(cnf.train_data_mnt) / "Garments2Look"
images_root = data_root / "images"
outfits_path = data_root / "polyvore_outfit_v1.0_2512.json"
run_dir = Path(cnf.train_output_mnt) / run_name
manifest_path = (
Path(cnf.train_output_mnt)
/ "manifests"
/ "garments2look_train_v1.jsonl"
)
model_dir = run_dir / "model"
state_path = run_dir / "state.pt"
run_dir.mkdir(parents=True, exist_ok=True)
manifest_path.parent.mkdir(parents=True, exist_ok=True)
def load_manifest() -> list[dict]:
if manifest_path.exists():
with manifest_path.open("r", encoding="utf-8") as file:
rows = [json.loads(line) for line in file]
print(f"Loaded manifest with {len(rows):,} images")
return rows
if not outfits_path.exists():
raise FileNotFoundError(
f"Outfit JSON not found: {outfits_path}"
)
if not images_root.exists():
raise FileNotFoundError(
f"Images directory not found: {images_root}"
)
image_extensions = {".jpg", ".jpeg", ".png", ".webp"}
image_index: dict[tuple[str, str], str] = {}
gender_directories = sorted(
path for path in images_root.iterdir() if path.is_dir()
)
for gender_directory in gender_directories:
gender = gender_directory.name.lower()
for image_path in tqdm(
gender_directory.rglob("*"),
desc=f"Indexing {gender}",
):
if (
image_path.is_file()
and image_path.suffix.lower() in image_extensions
):
image_index.setdefault(
(gender, image_path.stem),
str(image_path),
)
print(f"Indexed {len(image_index):,} images")
with outfits_path.open("r", encoding="utf-8") as file:
outfits = json.load(file)
rows: list[dict] = []
skipped_outfits = 0
missing_images = 0
usable_outfits = 0
for outfit_id, outfit_data in tqdm(
outfits.items(),
desc="Building outfit manifest",
):
if outfit_data.get("section") != "train":
continue
gender = str(outfit_data.get("gender", "")).lower()
outfit_items = outfit_data.get("outfit", {})
found_items = []
for item_id, description in outfit_items.items():
item_id = str(item_id)
image_path = image_index.get((gender, item_id))
if image_path is None:
missing_images += 1
continue
found_items.append(
{
"image": image_path,
"outfit": str(outfit_id),
"item_id": item_id,
"description": description,
"gender": gender,
}
)
if len(found_items) < items_per_outfit:
skipped_outfits += 1
continue
rows.extend(found_items)
usable_outfits += 1
if not rows:
raise RuntimeError(
"No usable outfit items were matched to image files"
)
with manifest_path.open("w", encoding="utf-8") as file:
for row in rows:
file.write(json.dumps(row, ensure_ascii=False) + "\n")
output_volume.commit()
print(f"Usable outfits: {usable_outfits:,}")
print(f"Manifest images: {len(rows):,}")
print(f"Skipped outfits: {skipped_outfits:,}")
print(f"Missing image references: {missing_images:,}")
return rows
class Garments2LookDataset(Dataset):
def __init__(self, rows: list[dict]):
self.rows = rows
self.by_outfit: dict[str, list[int]] = defaultdict(list)
for index, row in enumerate(rows):
self.by_outfit[row["outfit"]].append(index)
self.outfits = [
outfit_id
for outfit_id, indices in self.by_outfit.items()
if len(indices) >= items_per_outfit
]
def __len__(self):
return len(self.rows)
def __getitem__(self, index):
row = self.rows[index]
try:
with Image.open(row["image"]) as image:
image = image.convert("RGB")
except (OSError, UnidentifiedImageError):
return None
return image, row["outfit"]
class OutfitSampler(Sampler):
def __init__(self, dataset: Garments2LookDataset):
self.dataset = dataset
self.epoch = 0
def set_epoch(self, epoch: int):
self.epoch = epoch
def __len__(self):
return len(self.dataset.outfits) // outfits_per_batch
def __iter__(self):
rng = random.Random(seed + self.epoch)
outfits = self.dataset.outfits.copy()
rng.shuffle(outfits)
usable = (
len(outfits)
// outfits_per_batch
* outfits_per_batch
)
for start in range(0, usable, outfits_per_batch):
batch = []
for outfit_id in outfits[
start : start + outfits_per_batch
]:
batch.extend(
rng.sample(
self.dataset.by_outfit[outfit_id],
items_per_outfit,
)
)
rng.shuffle(batch)
yield batch
def collate(batch):
batch = [sample for sample in batch if sample is not None]
if len(batch) < 4:
return None
outfit_counts = Counter(
outfit_id for _, outfit_id in batch
)
# Remove outfits that lost an image because of a corrupted file.
batch = [
sample
for sample in batch
if outfit_counts[sample[1]] >= 2
]
if len(batch) < 4:
return None
images, outfit_ids = zip(*batch)
if len(set(outfit_ids)) < 2:
return None
label_map = {
outfit_id: index
for index, outfit_id in enumerate(
sorted(set(outfit_ids))
)
}
labels = torch.tensor(
[label_map[outfit_id] for outfit_id in outfit_ids],
dtype=torch.long,
)
return list(images), labels
def supcon_loss(embeddings, labels):
embeddings = F.normalize(
embeddings.float(),
dim=-1,
)
labels = labels.to(embeddings.device)
logits = embeddings @ embeddings.T
logits = logits / temperature
logits -= logits.max(
dim=1,
keepdim=True,
).values.detach()
batch_size = labels.size(0)
identity = torch.eye(
batch_size,
dtype=torch.bool,
device=labels.device,
)
positives = (
labels[:, None].eq(labels[None, :])
& ~identity
)
exp_logits = torch.exp(logits) * ~identity
log_prob = logits - torch.log(
exp_logits.sum(
dim=1,
keepdim=True,
).clamp_min(1e-12)
)
positive_count = positives.sum(dim=1)
valid = positive_count > 0
loss = (
(positives * log_prob).sum(dim=1)
/ positive_count.clamp_min(1)
)
return -loss[valid].mean()
def encode_images(model, images, device):
features = model[0].preprocess(images)
features = {
key: (
value.to(device, non_blocking=True)
if torch.is_tensor(value)
else value
)
for key, value in features.items()
}
return model(features)["sentence_embedding"]
def configure_model(model):
clip = model[0].auto_model
for parameter in clip.parameters():
parameter.requires_grad = False
if unfreeze_blocks < 0:
for parameter in clip.vision_model.parameters():
parameter.requires_grad = True
elif unfreeze_blocks > 0:
layers = clip.vision_model.encoder.layers
if unfreeze_blocks > len(layers):
raise ValueError(
f"Model only has {len(layers)} vision blocks"
)
for layer in layers[-unfreeze_blocks:]:
for parameter in layer.parameters():
parameter.requires_grad = True
for parameter in (
clip.vision_model.post_layernorm.parameters()
):
parameter.requires_grad = True
for parameter in clip.visual_projection.parameters():
parameter.requires_grad = True
def save_checkpoint(epoch, step, elapsed):
model.save(str(model_dir))
torch.save(
{
"epoch": epoch,
"step": step,
"optimizer": optimizer.state_dict(),
"scaler": scaler.state_dict(),
},
state_path,
)
output_volume.commit()
print(
f"\nCheckpoint saved: "
f"epoch={epoch}, step={step}, "
f"minutes={elapsed / 60:.1f}"
)
rows = load_manifest()
dataset = Garments2LookDataset(rows)
sampler = OutfitSampler(dataset)
loader_kwargs = {
"dataset": dataset,
"batch_sampler": sampler,
"collate_fn": collate,
"num_workers": num_workers,
"pin_memory": True,
"persistent_workers": num_workers > 0,
}
if num_workers > 0:
loader_kwargs["prefetch_factor"] = 2
loader = DataLoader(**loader_kwargs)
print(f"Images: {len(dataset):,}")
print(f"Outfits: {len(dataset.outfits):,}")
print(f"Batches per epoch: {len(loader):,}")
print(f"Batch size: {outfits_per_batch * items_per_outfit}")
device = torch.device("cuda")
model_source = (
str(model_dir)
if resume and model_dir.exists()
else cnf.train_model_name
)
model = SentenceTransformer(model_source)
model.to(device)
configure_model(model)
parameters = [
parameter
for parameter in model.parameters()
if parameter.requires_grad
]
print(
"Trainable parameters: "
f"{sum(parameter.numel() for parameter in parameters):,}"
)
optimizer = torch.optim.AdamW(
parameters,
lr=lr,
weight_decay=weight_decay,
)
scaler = torch.amp.GradScaler("cuda")
epoch = 0
step = 0
if resume and state_path.exists() and model_dir.exists():
state = torch.load(
state_path,
map_location=device,
weights_only=False,
)
optimizer.load_state_dict(state["optimizer"])
scaler.load_state_dict(state["scaler"])
epoch = int(state.get("epoch", 0))
step = int(state.get("step", 0))
print(f"Resuming at epoch={epoch}, step={step}")
model.train()
started = time.monotonic()
last_checkpoint = started
max_seconds = max_train_minutes * 60
checkpoint_seconds = checkpoint_every_minutes * 60
while time.monotonic() - started < max_seconds:
sampler.set_epoch(epoch)
progress = tqdm(
loader,
desc=f"Epoch {epoch + 1}",
)
for batch in progress:
elapsed = time.monotonic() - started
if elapsed >= max_seconds:
break
if batch is None:
continue
images, labels = batch
labels = labels.to(device, non_blocking=True)
optimizer.zero_grad(set_to_none=True)
with torch.autocast(
"cuda",
dtype=torch.float16,
):
embeddings = encode_images(
model,
images,
device,
)
loss = supcon_loss(
embeddings,
labels,
)
scaler.scale(loss).backward()
scaler.unscale_(optimizer)
torch.nn.utils.clip_grad_norm_(
parameters,
1.0,
)
scaler.step(optimizer)
scaler.update()
step += 1
progress.set_postfix(
loss=f"{loss.item():.4f}",
step=step,
minutes=f"{elapsed / 60:.1f}",
)
if (
time.monotonic() - last_checkpoint
>= checkpoint_seconds
):
save_checkpoint(
epoch=epoch,
step=step,
elapsed=elapsed,
)
last_checkpoint = time.monotonic()
epoch += 1
elapsed = time.monotonic() - started
save_checkpoint(
epoch=epoch,
step=step,
elapsed=elapsed,
)
print(
f"Training finished after "
f"{elapsed / 60:.1f} minutes"
)
print(f"Model saved to {model_dir}")
return str(model_dir)
@app.local_entrypoint()
def main(
run_name: str = "clip-garments2look-1hour",
max_train_minutes: int = 55,
checkpoint_every_minutes: int = 10,
lr: float = 1e-5,
temperature: float = 0.07,
outfits_per_batch: int = 32,
items_per_outfit: int = 2,
num_workers: int = 8,
unfreeze_blocks: int = 2,
resume: bool = True,
):
train.remote(
run_name=run_name,
max_train_minutes=max_train_minutes,
checkpoint_every_minutes=checkpoint_every_minutes,
lr=lr,
temperature=temperature,
outfits_per_batch=outfits_per_batch,
items_per_outfit=items_per_outfit,
num_workers=num_workers,
unfreeze_blocks=unfreeze_blocks,
resume=resume,
)