Text Classification
Transformers
Safetensors
English
nli
cross-encoder
qwen3.5
reranker
image-text-to-text
Instructions to use ldov/openjevv with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use ldov/openjevv with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("text-classification", model="ldov/openjevv")# Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("ldov/openjevv", device_map="auto") - Notebooks
- Google Colab
- Kaggle
| #!/usr/bin/env python | |
| """Fine-tune a Qwen3.5 model as a 3-way NLI cross-encoder on AllNLI (SNLI + MNLI). | |
| Label order follows dleemiller/ModernCE-large-nli: 0=contradiction, 1=entailment, 2=neutral. | |
| Usage: | |
| python train.py --model Qwen/Qwen3.5-0.8B --out ckpt/qwen3.5-0.8b-nli | |
| python train.py --model Qwen/Qwen3.5-9B --out ckpt/qwen3.5-9b-nli --lora --grad-ckpt | |
| """ | |
| import argparse | |
| import json | |
| import math | |
| import os | |
| import random | |
| import functools | |
| import numpy as np | |
| import torch | |
| from datasets import concatenate_datasets, load_dataset, load_from_disk | |
| from transformers import ( | |
| AutoModelForSequenceClassification, | |
| AutoTokenizer, | |
| DataCollatorWithPadding, | |
| Trainer, | |
| TrainingArguments, | |
| ) | |
| ID2LABEL = {0: "contradiction", 1: "entailment", 2: "neutral"} | |
| LABEL2ID = {v: k for k, v in ID2LABEL.items()} | |
| # SNLI / MNLI native: 0=entailment, 1=neutral, 2=contradiction | |
| NATIVE2OURS = {0: 1, 1: 2, 2: 0} | |
| TEMPLATE = "Premise: {premise}\nHypothesis: {hypothesis}" | |
| IMG_MARK = "<<IMG>>" # data_mix.py puts this where the image-token block must go | |
| def format_pair(premise: str, hypothesis: str) -> str: | |
| return TEMPLATE.format(premise=premise.strip(), hypothesis=hypothesis.strip()) | |
| def load_allnli(n_train: int, n_val: int, seed: int): | |
| snli = load_dataset("stanfordnlp/snli", split="train") | |
| mnli = load_dataset("nyu-mll/multi_nli", split="train") | |
| cols = ["premise", "hypothesis", "label"] | |
| train = concatenate_datasets([snli.select_columns(cols), mnli.select_columns(cols)]) | |
| train = train.filter(lambda x: x["label"] in (0, 1, 2), num_proc=8) | |
| train = train.shuffle(seed=seed).select(range(min(n_train, len(train)))) | |
| val = load_dataset("nyu-mll/multi_nli", split="validation_matched").select_columns(cols) | |
| val = val.filter(lambda x: x["label"] in (0, 1, 2)).shuffle(seed=seed).select(range(n_val)) | |
| return train, val | |
| def load_mix(path, n_train, n_val, seed): | |
| """Mixture rows carry labels ALREADY in our space (0=con,1=ent,2=neu) -- NATIVE2OURS is never applied here.""" | |
| ds = load_from_disk(os.path.join(path, "mix")) | |
| train, val = ds["train"], ds["val"] | |
| if n_train and n_train < len(train): | |
| train = train.shuffle(seed=seed).select(range(n_train)) | |
| if n_val and n_val < len(val): | |
| val = val.select(range(n_val)) | |
| labels = set(train.unique("label")) | |
| assert labels <= {0, 1, 2}, f"mixture labels out of range: {labels}" | |
| return train, val | |
| class FastPatchEmbed(torch.nn.Module): | |
| """Qwen3.5's vision patch embed is a Conv3d whose bf16 cuDNN path costs ~2 s/frame; fp32 costs 0.3 ms. | |
| `autocast(enabled=False)` is mandatory under Trainer: accelerate wraps forward in bf16 autocast, which would cast | |
| the inputs straight back and restore the slow path. `weight` stays bf16 because the caller reads its dtype.""" | |
| def __init__(self, conv): | |
| super().__init__() | |
| self.weight, self.bias, self.stride = conv.weight, conv.bias, conv.stride | |
| def forward(self, x): | |
| with torch.autocast("cuda", enabled=False): | |
| y = torch.nn.functional.conv3d(x.float(), self.weight.float(), self.bias.float(), stride=self.stride) | |
| return y.to(self.weight.dtype) | |
| def freeze_vision(model, fast_patch=True): | |
| """Freeze the vision tower, make its patch embed fast, and run it under no_grad (it is frozen, so nothing flows; | |
| this also skips the pointless checkpoint wrapper gradient_checkpointing_enable() installs on each vision block).""" | |
| n_vis = 0 | |
| for n, p in model.named_parameters(): | |
| if "visual" in n: | |
| p.requires_grad = False | |
| n_vis += p.numel() | |
| vis = getattr(getattr(model, "model", model), "visual", None) | |
| if vis is not None and fast_patch: | |
| vis.patch_embed.proj = FastPatchEmbed(vis.patch_embed.proj) | |
| orig = vis.forward | |
| def fwd(*a, **k): | |
| with torch.no_grad(): | |
| return orig(*a, **k) | |
| vis.forward = fwd | |
| print(f"frozen visual params: {n_vis/1e6:.1f}M" + (" (fp32 patch-embed, no_grad forward)" if vis is not None and fast_patch else "")) | |
| return model | |
| class DataCollatorNLIMM: | |
| """Pads pre-tokenized rows and, for rows that carry a JPEG, adds pixel_values / image_grid_thw / mm_token_type_ids. | |
| A batch with no image rows gets none of the three keys, so it is byte-identical to DataCollatorWithPadding.""" | |
| KEYS = ("input_ids", "attention_mask", "labels") | |
| def __init__(self, tok, image_processor, img_id, image_root="", pad_to_multiple_of=8): | |
| self.tok, self.ip, self.img_id, self.root = tok, image_processor, img_id, image_root | |
| self.pad_to_multiple_of = pad_to_multiple_of | |
| self.last = None | |
| def __call__(self, features): | |
| from PIL import Image | |
| paths = [f.get("image") or "" for f in features] | |
| batch = self.tok.pad([{k: f[k] for k in self.KEYS if k in f} for f in features], | |
| padding=True, pad_to_multiple_of=self.pad_to_multiple_of, return_tensors="pt") | |
| imgs = [p for p in paths if p] | |
| if not imgs: | |
| return batch | |
| pil = [] | |
| for p in imgs: | |
| try: | |
| pil.append(Image.open(os.path.join(self.root, p)).convert("RGB")) | |
| except Exception as e: # noqa: BLE001 | |
| print(f"[collator] bad image {p}: {type(e).__name__}", flush=True) | |
| pil.append(Image.new("RGB", (320, 240), (128, 128, 128))) | |
| vis = self.ip(images=pil, return_tensors="pt") | |
| batch["pixel_values"] = vis["pixel_values"] | |
| batch["image_grid_thw"] = vis["image_grid_thw"] | |
| batch["mm_token_type_ids"] = (batch["input_ids"] == self.img_id).long() | |
| n_tok = int(batch["mm_token_type_ids"].sum()) | |
| n_feat = int((vis["image_grid_thw"].prod(-1) // self.ip.merge_size ** 2).sum()) | |
| if n_tok != n_feat: | |
| raise ValueError(f"image-pad tokens {n_tok} != merged patches {n_feat} (rows={len(features)}, imgs={imgs[:3]})") | |
| self.last = batch | |
| return batch | |
| class MixTrainer(Trainer): | |
| """Trainer whose length-grouped sampler gets lengths as a plain Python list. datasets 5.0 returns a lazy Arrow | |
| `Column` for ds["length"]; LengthGroupedSampler sorts megabatches with `lengths[i]` (~32 us per access through | |
| Arrow), which for 1.26M rows stalls the first step for 10+ minutes on every rank.""" | |
| def __init__(self, *a, train_lengths=None, **k): | |
| super().__init__(*a, **k) | |
| self._train_lengths = train_lengths | |
| def _get_train_sampler(self, train_dataset=None): | |
| if self.args.train_sampling_strategy == "group_by_length" and self._train_lengths is not None: | |
| from transformers.trainer_pt_utils import LengthGroupedSampler | |
| return LengthGroupedSampler(self.args.train_batch_size * self.args.gradient_accumulation_steps, | |
| lengths=self._train_lengths) | |
| return super()._get_train_sampler(train_dataset) | |
| def add_smoke_callback(trainer, collator, tok, img_id, n_img_tokens): | |
| """One-shot check that the image path is real: token/patch counts line up, blanking the pixels moves the image | |
| rows' logits and leaves the text rows bit-identical, and the vision tower is frozen and fast.""" | |
| from transformers import TrainerCallback | |
| class Smoke(TrainerCallback): | |
| """The real collator runs inside dataloader workers, so the check builds its own batch in-process.""" | |
| t = {} | |
| def on_step_end(self, a, state, control, model=None, **kw): | |
| import time | |
| if state.global_step in (5, 35): | |
| torch.cuda.synchronize(); self.t[state.global_step] = time.perf_counter() | |
| if state.global_step == 35: | |
| print(f"[smoke] step time {(self.t[35]-self.t[5])/30:.3f} s/step (steps 5..35)", flush=True) | |
| if state.global_step != 1: | |
| return control | |
| ds = trainer.train_dataset | |
| scan = range(min(4000, len(ds))) | |
| img_idx = [i for i in scan if ds[i]["image"]][:6] | |
| txt_idx = [i for i in scan if not ds[i]["image"]][:6] | |
| assert img_idx, "no image rows in the first 4000 training rows" | |
| b = collator([ds[i] for i in txt_idx[:3] + img_idx + txt_idx[3:]]) | |
| b = {k: (v.cuda() if hasattr(v, "cuda") else v) for k, v in b.items()} | |
| n_tok = int((b["input_ids"] == img_id).sum()) | |
| n_rows = int(b["image_grid_thw"].shape[0]) | |
| assert n_tok == n_img_tokens * n_rows, (n_tok, n_img_tokens, n_rows) | |
| assert b["mm_token_type_ids"].shape == b["input_ids"].shape | |
| assert (b["attention_mask"].diff(dim=1) <= 0).all(), "batch is not right-padded" | |
| m = model.module if hasattr(model, "module") else model | |
| assert not any(p.requires_grad for n, p in m.named_parameters() if "visual" in n), "vision tower is trainable" | |
| feed = {k: v for k, v in b.items() if k != "labels"} | |
| was_training = m.training | |
| m.eval() | |
| with torch.no_grad(), torch.autocast("cuda", dtype=torch.bfloat16): | |
| lg = m(**feed).logits.float() | |
| blank = dict(feed); blank["pixel_values"] = torch.zeros_like(feed["pixel_values"]) | |
| lg0 = m(**blank).logits.float() | |
| rows = b["mm_token_type_ids"].sum(1) > 0 | |
| d_img = float((lg[rows] - lg0[rows]).abs().max()) | |
| d_txt = float((lg[~rows] - lg0[~rows]).abs().max()) if (~rows).any() else 0.0 | |
| assert d_img > 1e-2, f"blanking the image moved image-row logits by only {d_img} -- the tower is dead" | |
| assert d_txt < 1e-3, f"blanking the image moved TEXT rows by {d_txt} -- cross-row leakage" | |
| with torch.no_grad(), torch.autocast("cuda", dtype=torch.bfloat16): | |
| m.model.visual(b["pixel_values"], grid_thw=b["image_grid_thw"]) | |
| torch.cuda.synchronize(); t0 = time.perf_counter() | |
| for _ in range(5): | |
| m.model.visual(b["pixel_values"], grid_thw=b["image_grid_thw"]) | |
| torch.cuda.synchronize() | |
| dt = (time.perf_counter() - t0) / 5 | |
| if was_training: | |
| m.train() | |
| assert dt < 0.2, f"vision forward {dt*1e3:.0f} ms under autocast -- FastPatchEmbed lost to autocast" | |
| print(f"[smoke] OK: image rows {n_rows}/{b['input_ids'].shape[0]}, shape {tuple(b['input_ids'].shape)}, " | |
| f"d_img {d_img:.3f} d_text {d_txt:.5f}, vision fwd {dt*1e3:.1f} ms for {n_rows} images, " | |
| f"peak {torch.cuda.max_memory_allocated()/2**30:.1f} GiB", flush=True) | |
| return control | |
| trainer.add_callback(Smoke()) | |
| def main(): | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--model", default="Qwen/Qwen3.5-0.8B") | |
| ap.add_argument("--out", required=True) | |
| ap.add_argument("--n-train", type=int, default=200_000) | |
| ap.add_argument("--n-val", type=int, default=2000) | |
| ap.add_argument("--max-len", type=int, default=256) | |
| ap.add_argument("--bs", type=int, default=32) | |
| ap.add_argument("--grad-accum", type=int, default=1) | |
| ap.add_argument("--lr", type=float, default=2e-5) | |
| ap.add_argument("--epochs", type=float, default=1.0) | |
| ap.add_argument("--lora", action="store_true") | |
| ap.add_argument("--lora-r", type=int, default=16) | |
| ap.add_argument("--lora-targets", default="q_proj,k_proj,v_proj,o_proj,gate_proj,up_proj,down_proj,in_proj_qkv,in_proj_z,in_proj_a,in_proj_b,out_proj") | |
| ap.add_argument("--grad-ckpt", action="store_true") | |
| ap.add_argument("--head-only", action="store_true", help="freeze the backbone, train only the `score` head") | |
| ap.add_argument("--eval-steps", type=int, default=1000) | |
| ap.add_argument("--seed", type=int, default=42) | |
| ap.add_argument("--max-steps", type=int, default=-1, help="debug: stop early") | |
| ap.add_argument("--device-map", default=None, help='e.g. "auto" to shard a big model over all visible GPUs') | |
| ap.add_argument("--data", choices=["allnli", "mix"], default="allnli") | |
| ap.add_argument("--mix-dir", default=None, help="dir written by data_mix.py build (holds mix/ and images/)") | |
| ap.add_argument("--image-processor", default="Qwen/Qwen3.5-4B") | |
| ap.add_argument("--group-by-length", action="store_true") | |
| ap.add_argument("--save-steps", type=int, default=0) | |
| ap.add_argument("--smoke", action="store_true", help="assert the image path really works, then keep training") | |
| ap.add_argument("--eval-mnli", action="store_true", default=True, help="keep MNLI-m as a second eval set") | |
| args = ap.parse_args() | |
| random.seed(args.seed) | |
| np.random.seed(args.seed) | |
| torch.manual_seed(args.seed) | |
| if "moe" in args.model.lower() or "a3b" in args.model.lower(): | |
| import modeling_qwen35_moe_seqcls # noqa: F401 registers Qwen3_5MoeForSequenceClassification | |
| tok = AutoTokenizer.from_pretrained(args.model) | |
| if tok.pad_token is None: | |
| tok.pad_token = tok.eos_token | |
| tok.padding_side = "right" | |
| def encode(batch): | |
| texts = [format_pair(p, h) for p, h in zip(batch["premise"], batch["hypothesis"])] | |
| enc = tok(texts, truncation=True, max_length=args.max_len) | |
| enc["labels"] = [NATIVE2OURS[l] for l in batch["label"]] | |
| return enc | |
| if args.data == "allnli": | |
| train, val = load_allnli(args.n_train, args.n_val, args.seed) | |
| print(f"train={len(train)} val={len(val)}") | |
| train = train.map(encode, batched=True, remove_columns=train.column_names, num_proc=8) | |
| val = val.map(encode, batched=True, remove_columns=val.column_names) | |
| eval_sets = val | |
| else: | |
| assert args.mix_dir, "--data mix needs --mix-dir" | |
| img_id = tok.convert_tokens_to_ids("<|image_pad|>") | |
| img_block = None # filled once the image processor tells us the token count | |
| from transformers import AutoImageProcessor | |
| image_processor = AutoImageProcessor.from_pretrained(args.image_processor) | |
| from PIL import Image as _PILImage | |
| _probe = image_processor(images=[_PILImage.new("RGB", (320, 240))], return_tensors="pt") | |
| n_img_tokens = int(_probe["image_grid_thw"].prod()) // image_processor.merge_size ** 2 | |
| img_block = "<|vision_start|>" + "<|image_pad|>" * n_img_tokens + "<|vision_end|>" | |
| print(f"image block: {n_img_tokens} tokens, pixel_values {tuple(_probe['pixel_values'].shape)}") | |
| def encode_mix(batch): | |
| """Labels pass through untouched (already ours). Over-long rows are truncated on the PREMISE side, so the | |
| hypothesis -- and the image block that sits inside the premise -- always survive.""" | |
| ids_out, am_out, lab, ln, im_out = [], [], [], [], [] | |
| for pr, hy, y, im in zip(batch["premise"], batch["hypothesis"], batch["label"], batch["image"]): | |
| pr = pr.replace(IMG_MARK, img_block) if im else pr | |
| ids = tok(format_pair(pr, hy), add_special_tokens=False)["input_ids"] | |
| if len(ids) > args.max_len: | |
| left = tok("Premise: " + pr.strip(), add_special_tokens=False)["input_ids"] | |
| right = tok("\nHypothesis: " + hy.strip(), add_special_tokens=False)["input_ids"][: args.max_len - 16] | |
| keep = max(args.max_len - len(right), 16) | |
| if im: # never cut into the image block: keep its head, drop text after it | |
| first = next((i for i, t in enumerate(left) if t == img_id), None) | |
| if first is not None: | |
| keep = max(keep, first + n_img_tokens + 2) | |
| ids = (left[:keep] + right)[: max(args.max_len, keep + len(right))] | |
| if im: | |
| n = sum(1 for t in ids if t == img_id) | |
| if n != n_img_tokens: | |
| continue # never feed a row whose image span got clipped | |
| ids_out.append(ids); am_out.append([1] * len(ids)) | |
| lab.append(int(y)); ln.append(len(ids)); im_out.append(im or "") | |
| return {"input_ids": ids_out, "attention_mask": am_out, "labels": lab, "length": ln, "image": im_out} | |
| train, val = load_mix(args.mix_dir, args.n_train if args.n_train != 200_000 else 0, args.n_val, args.seed) | |
| print(f"train={len(train)} val={len(val)} (mixture)") | |
| cols = train.column_names | |
| from accelerate import PartialState | |
| _state = PartialState() # under torchrun: rank 0 tokenizes and writes the cache, the others then read it | |
| with _state.main_process_first(): | |
| train = train.map(encode_mix, batched=True, remove_columns=cols, num_proc=8) | |
| val = val.map(encode_mix, batched=True, remove_columns=cols, num_proc=4) | |
| eval_sets = {"mix": val} | |
| if args.eval_mnli: | |
| with _state.main_process_first(): | |
| _, mnli_val = load_allnli(1, args.n_val, args.seed) | |
| mnli_val = mnli_val.map( | |
| lambda b: {**encode(b), "length": [len(x) for x in encode(b)["input_ids"]], "image": [""] * len(b["label"])}, | |
| batched=True, remove_columns=mnli_val.column_names) | |
| eval_sets["mnli"] = mnli_val | |
| L = np.array(train["length"]) | |
| print(f"[mix] tokens: mean {L.mean():.0f} p50 {np.percentile(L,50):.0f} p90 {np.percentile(L,90):.0f} " | |
| f"max {L.max()} | total {L.sum()/1e6:.0f}M | image rows {sum(1 for x in train['image'] if x)}") | |
| cls = AutoModelForSequenceClassification | |
| if "moe" in args.model.lower() or "a3b" in args.model.lower(): | |
| from modeling_qwen35_moe_seqcls import Qwen3_5MoeForSequenceClassification as cls | |
| model = cls.from_pretrained( | |
| args.model, | |
| num_labels=3, | |
| id2label=ID2LABEL, | |
| label2id=LABEL2ID, | |
| dtype=torch.bfloat16, | |
| device_map=args.device_map, | |
| ) | |
| # Qwen3.5 config is composite (text_config inside); the seq-cls head reads get_text_config().pad_token_id | |
| model.config.get_text_config().pad_token_id = tok.pad_token_id | |
| model.config.pad_token_id = tok.pad_token_id | |
| model.config.nli_template = TEMPLATE # consumed by eval.py | |
| model.config.use_cache = False | |
| # Qwen3.5 checkpoints carry a vision tower; it stays frozen in both paths (the mix path also makes it fast). | |
| freeze_vision(model, fast_patch=(args.data == "mix")) | |
| if args.head_only: | |
| for n, p in model.named_parameters(): | |
| p.requires_grad = n.startswith("score") | |
| n_tr = sum(p.numel() for p in model.parameters() if p.requires_grad) | |
| print(f"head-only: trainable params {n_tr/1e3:.1f}K") | |
| if args.lora: | |
| from peft import LoraConfig, TaskType, get_peft_model | |
| lcfg = LoraConfig( | |
| task_type=TaskType.SEQ_CLS, | |
| r=args.lora_r, | |
| lora_alpha=2 * args.lora_r, | |
| lora_dropout=0.05, | |
| target_modules=args.lora_targets.split(","), | |
| modules_to_save=["score"], | |
| ) | |
| model = get_peft_model(model, lcfg) | |
| model.print_trainable_parameters() | |
| def compute_metrics(p): | |
| logits = p.predictions[0] if isinstance(p.predictions, (tuple, list)) else p.predictions | |
| preds = logits.argmax(-1) | |
| return {"accuracy": float((preds == p.label_ids).mean())} | |
| world = int(os.environ.get("WORLD_SIZE", 1)) | |
| total_steps = args.max_steps if args.max_steps > 0 else int( | |
| math.ceil(len(train) / (args.bs * args.grad_accum * world)) * args.epochs) | |
| targs = TrainingArguments( | |
| output_dir=args.out + "_trainer", | |
| per_device_train_batch_size=args.bs, | |
| per_device_eval_batch_size=64, | |
| gradient_accumulation_steps=args.grad_accum, | |
| learning_rate=args.lr, | |
| lr_scheduler_type="cosine", | |
| warmup_steps=max(1, int(0.03 * total_steps)), # warmup_ratio was removed in transformers 5.15 | |
| weight_decay=0.01, | |
| num_train_epochs=args.epochs, | |
| max_steps=args.max_steps, | |
| bf16=True, | |
| gradient_checkpointing=args.grad_ckpt, | |
| logging_steps=10 if args.smoke else 25, | |
| eval_strategy="steps", | |
| eval_steps=args.eval_steps, | |
| save_strategy="steps" if args.save_steps else "no", | |
| save_steps=args.save_steps or 500, | |
| save_total_limit=1, | |
| report_to="none", | |
| dataloader_num_workers=6 if args.data == "mix" else 4, | |
| seed=args.seed, | |
| remove_unused_columns=False, | |
| label_names=["labels"], # transformers 5.x leaves this empty -> no eval loss/metrics otherwise | |
| ddp_broadcast_buffers=False, | |
| **({"train_sampling_strategy": "group_by_length", "length_column_name": "length"} if args.group_by_length else {}), | |
| ) | |
| collator = (DataCollatorNLIMM(tok, image_processor, img_id, image_root=args.mix_dir) | |
| if args.data == "mix" else DataCollatorWithPadding(tok)) | |
| trainer = MixTrainer( | |
| model=model, | |
| args=targs, | |
| train_dataset=train, | |
| eval_dataset=eval_sets, | |
| data_collator=collator, | |
| compute_metrics=compute_metrics, | |
| train_lengths=[int(x) for x in np.asarray(train["length"])] if (args.data == "mix" and args.group_by_length) else None, | |
| ) | |
| if args.smoke: | |
| add_smoke_callback(trainer, collator, tok, img_id, n_img_tokens) | |
| trainer.train() | |
| final = trainer.evaluate() | |
| print("final eval:", final) | |
| if args.lora: | |
| model = model.merge_and_unload() | |
| model.config.nli_template = TEMPLATE | |
| os.makedirs(args.out, exist_ok=True) | |
| model.save_pretrained(args.out) | |
| tok.save_pretrained(args.out) | |
| with open(os.path.join(args.out, "train_result.json"), "w") as f: | |
| json.dump({"args": vars(args), "final_eval": final}, f, indent=2) | |
| print("saved to", args.out) | |
| if __name__ == "__main__": | |
| main() | |