"""IFD (Instruction Following Difficulty) data quality scoring. IFD = conditional_NLL / unconditional_NLL - Messages format: plain text concatenation (no chat template) - Plain format: raw instr_key + resp_key fields v2 changelog: - Same token set: unconditional pass prefixes resp with a plain-text sentinel (default ``\\n``; use ``--sentinel_text ""`` for bos/pad fallback). Both branches predict the identical N resp tokens. Single-token answers (rl=1) are now supported. - ctx_len tracked in output - skip_reason for None samples (no more silent None) - --per_token for per-token IFD breakdown """ import argparse import glob import json import os import statistics import torch import torch.nn.functional as F import tqdm from astrai.model import AutoModel from astrai.tokenize import AutoTokenizer def _pack_bins(pairs, max_len): """BFD bin packing: pack (c+r) into bins of max total length.""" indexed = sorted(enumerate(pairs), key=lambda x: -(len(x[1][0]) + len(x[1][1]))) bins = [] lengths = [] for orig_idx, (c, r) in indexed: size = len(c) + len(r) best_bin = -1 for bi, rem in enumerate(lengths): if rem >= size: if best_bin < 0 or rem < lengths[best_bin]: best_bin = bi if best_bin >= 0: bins[best_bin].append((orig_idx, c, r)) lengths[best_bin] -= size else: bins.append([(orig_idx, c, r)]) lengths.append(max_len - size) return bins def _resolve_sentinel_ids(tokenizer, sentinel_text): """Tokenize the sentinel text for the unconditional pass prefix. Falls back to bos/pad_token_id when sentinel_text is empty or cannot be encoded. """ if sentinel_text: ids = tokenizer.encode(sentinel_text, add_special_tokens=False) if ids: return ids for attr in ("bos_token_id", "pad_token_id", "eos_token_id"): tid = getattr(tokenizer, attr, None) if tid is not None: return [tid] return [0] def _collect_input_files(input_path: str) -> list: """Resolve *input_path* to a list of JSONL/JSON files.""" if os.path.isdir(input_path): files = [] for ext in ("*.jsonl", "*.json"): files.extend( sorted(glob.glob(os.path.join(input_path, "**", ext), recursive=True)) ) return files return sorted(glob.glob(input_path)) def _load_items(filepath: str) -> list: """Load JSONL or JSON (array / single dict) into a list of dicts.""" with open(filepath, "r", encoding="utf-8") as f: if filepath.lower().endswith(".json"): data = json.load(f) if isinstance(data, dict): return [data] return data return [json.loads(line) for line in f if line.strip()] @torch.inference_mode() def _score_batch( pairs, model, device, max_len=2048, sentinel_ids=None, per_token=False ): """BFD-packed IFD with text-sentinel-anchored unconditional pass. Conditional: (ctx + resp[0..i-1]) → resp[i], i = 0..N-1 Unconditional: ( + resp[0..i-1]) → resp[i], i = 0..N-1 Both branches predict the identical N response tokens. A short plain-text sentinel gives the unconditional pass a prefix so that every response token can be predicted. Single-token answers (rl=1) are supported. """ if not pairs: return [] if sentinel_ids is None: sentinel_ids = [0] bins = _pack_bins(pairs, max_len) result = [None] * len(pairs) # ---- conditional pass (packed, per-document position IDs) ---- for bin_items in bins: seq_ids = [] global_pos = [] doc_ids = [] doc_offsets = [] for di, (orig_idx, c, r) in enumerate(bin_items): ctx_len = len(c) start = len(seq_ids) item_len = len(c) + len(r) seq_ids.extend(c) seq_ids.extend(r) end = len(seq_ids) global_pos.extend(range(item_len)) doc_ids.extend([di] * item_len) doc_offsets.append((start, end, orig_idx, ctx_len)) full_ids = torch.tensor([seq_ids], device=device, dtype=torch.long) pos_ids = torch.tensor([global_pos], device=device, dtype=torch.long) seq_len = len(seq_ids) causal = torch.tril( torch.ones(seq_len, seq_len, dtype=torch.bool, device=device) ) doc_t = torch.tensor([doc_ids], device=device) doc_mask = doc_t.unsqueeze(-1) == doc_t.unsqueeze(-2) attn_mask = (causal & doc_mask[0]).unsqueeze(0).unsqueeze(0) logits_full = model(full_ids, position_ids=pos_ids, input_mask=attn_mask)[ "logits" ][0] for start, end, orig_idx, ctx_len in doc_offsets: rl = end - start - ctx_len resp_start = start + ctx_len - 1 resp_logits = logits_full[resp_start : end - 1] resp_targets = torch.tensor( seq_ids[start + ctx_len : end], device=device, dtype=torch.long ) cond_losses = F.cross_entropy( resp_logits, resp_targets, reduction="none" ).cpu() result[orig_idx] = { "_cond_losses": cond_losses, "_rl": rl, "_ctx_len": ctx_len, } # ---- unconditional pass (sentinel-prefixed, batched 2D) ---- valid_items = [ ( i, result[i]["_rl"], result[i]["_ctx_len"], result[i]["_cond_losses"], pairs[i][1], ) for i in range(len(pairs)) if result[i] is not None and "_cond_losses" in result[i] ] if not valid_items: return result valid_items.sort(key=lambda x: -x[1]) prefix_len = len(sentinel_ids) max_rl = prefix_len + max(rl for _, rl, _, _, _ in valid_items) bsz = len(valid_items) u_batch = torch.zeros(bsz, max_rl, dtype=torch.long, device=device) for ri, (_, rl, _, _, r_ids) in enumerate(valid_items): u_batch[ri, :prefix_len] = torch.tensor(sentinel_ids, dtype=torch.long) u_batch[ri, prefix_len : prefix_len + rl] = torch.tensor( r_ids, dtype=torch.long ) logits_resp = model(u_batch)["logits"] for ri, (orig_idx, rl, ctx_len, cond_losses, _) in enumerate(valid_items): unp_logits = logits_resp[ri, prefix_len - 1 : prefix_len - 1 + rl] unp_targets = u_batch[ri, prefix_len : prefix_len + rl] uncond_losses = F.cross_entropy(unp_logits, unp_targets, reduction="none").cpu() L_cond = cond_losses.mean().item() L_uncond = uncond_losses.mean().item() ifd = L_cond / L_uncond if L_uncond > 0 else None out = { "L_cond": round(L_cond, 6), "L_uncond": round(L_uncond, 6), "ifd": round(ifd, 6) if ifd is not None else None, "ctx_len": ctx_len, "resp_len": rl, } if per_token: per = [ (round(c.item() / u.item(), 6) if u.item() > 0 else None) for c, u in zip(cond_losses, uncond_losses) ] out["ifd_per_token"] = per result[orig_idx] = out return result def _trim(context_ids, resp_ids, max_len): """Truncate to fit max_len, keeping response intact if possible.""" if len(resp_ids) > max_len // 2: resp_ids = resp_ids[: max_len // 2] full_ids = context_ids + resp_ids if len(full_ids) <= max_len: return context_ids, resp_ids overflow = len(full_ids) - max_len if overflow >= len(context_ids): return [], resp_ids[:max_len] return context_ids[overflow:], resp_ids def process_file( model, tokenizer, input_file, output_file, instr_key, resp_key, max_len=2048, data_format="plain", batch_size=1, device=None, sentinel_ids=None, per_token=False, max_samples=None, ): """Score a single file, write per-sample JSONL, return summary stats.""" if device is None: device = "cuda" if torch.cuda.is_available() else "cpu" if sentinel_ids is None: sentinel_ids = _resolve_sentinel_ids(tokenizer, "\n") data = _load_items(input_file) if max_samples and len(data) > max_samples: import random data = random.sample(data, max_samples) results = [] all_ifds = [] buffer = [] label = os.path.splitext(os.path.basename(input_file))[0] for item in tqdm.tqdm(data, desc=f" {label}", unit="sample", leave=False): if data_format == "messages": turns = [] for i, msg in enumerate(item.get("messages", [])): if msg.get("role") != "assistant": continue ctx_text = "\n\n".join(m["content"] for m in item["messages"][:i]) ctx_ids = tokenizer.encode(ctx_text) resp_ids = tokenizer.encode(msg["content"], add_special_tokens=False) ctx_ids, resp_ids = _trim(ctx_ids, resp_ids, max_len) if ctx_ids and resp_ids: turns.append((ctx_ids, resp_ids)) if not turns: results.append( { **item, "ifd": None, "skip_reason": "no valid assistant turns", "ifd_turns": [], } ) continue buffer.append((item, turns, "messages")) else: ctx_ids = tokenizer.encode(item[instr_key], add_special_tokens=False) resp_ids = tokenizer.encode(item[resp_key], add_special_tokens=False) ctx_ids, resp_ids = _trim(ctx_ids, resp_ids, max_len) if not ctx_ids or not resp_ids: results.append( { **item, "ifd": None, "ifd_detail": {"skip_reason": "empty ctx or resp"}, } ) continue buffer.append((item, [(ctx_ids, resp_ids)], "plain")) if len(buffer) >= batch_size: _flush_buffer( buffer, results, all_ifds, model, device, max_len, sentinel_ids, per_token, ) if buffer: _flush_buffer( buffer, results, all_ifds, model, device, max_len, sentinel_ids, per_token ) with open(output_file, "w", encoding="utf-8") as f: for item in results: f.write(json.dumps(item, ensure_ascii=False) + "\n") valid_ifd = [v for v in all_ifds if v is not None] stats = { "samples": len(data), "valid_ifd": len(valid_ifd), "skipped": len(data) - len(valid_ifd), } if valid_ifd: stats["mean_ifd"] = statistics.mean(valid_ifd) stats["median_ifd"] = statistics.median(valid_ifd) if len(valid_ifd) > 1: stats["stdev_ifd"] = statistics.stdev(valid_ifd) stats["min_ifd"] = min(valid_ifd) stats["max_ifd"] = max(valid_ifd) print(f"\n{'=' * 50}") print(f" [{label}]") print(f"{'=' * 50}") print(f" Samples: {len(data)}") print(f" Valid IFD: {len(valid_ifd)}") print(f" Skipped: {len(data) - len(valid_ifd)}") print(f" Mean IFD: {statistics.mean(valid_ifd):.4f}") print(f" Median IFD: {statistics.median(valid_ifd):.4f}") if len(valid_ifd) > 1: print(f" Stdev IFD: {statistics.stdev(valid_ifd):.4f}") print(f" Min IFD: {min(valid_ifd):.4f}") print(f" Max IFD: {max(valid_ifd):.4f}") print(f"{'=' * 50}") print(f" Results saved to {output_file}") return stats def _flush_buffer( buffer, results, all_ifds, model, device, max_len, sentinel_ids, per_token ): all_pairs = [] indices = [] for item, turns, fmt in buffer: start = len(all_pairs) all_pairs.extend(turns) indices.append((item, turns, fmt, start, len(all_pairs))) raw = _score_batch( all_pairs, model, device, max_len, sentinel_ids=sentinel_ids, per_token=per_token, ) for item, turns, fmt, start, end in indices: turn_scores = raw[start:end] if fmt == "messages": valid = [ s for s in turn_scores if s is not None and s.get("ifd") is not None ] if not valid: results.append({**item, "ifd": None, "ifd_turns": turn_scores}) else: avg = sum(s["ifd"] for s in valid) / len(valid) all_ifds.append(avg) results.append( { **item, "ifd": avg, "ifd_detail": valid[0] if len(valid) == 1 else None, "ifd_turns": turn_scores, } ) else: score = turn_scores[0] all_ifds.append(score.get("ifd")) results.append({**item, "ifd": score.get("ifd"), "ifd_detail": score}) buffer.clear() def main(): parser = argparse.ArgumentParser( description="Compute IFD scores for instruction-response data" ) parser.add_argument("--param_path", type=str, required=True, help="Model directory") parser.add_argument( "--input_path", type=str, required=True, help="Input file, glob pattern, or directory.", ) parser.add_argument( "--output_dir", type=str, required=True, help="Directory for output files (summary.json + per-file JSONL).", ) parser.add_argument("--max_len", type=int, default=2048, help="Max token length") parser.add_argument( "--format", type=str, default="plain", choices=["plain", "messages"], help="Input format", ) parser.add_argument( "--instr_key", type=str, default="instruction", help="Key for instruction field" ) parser.add_argument( "--resp_key", type=str, default="response", help="Key for response field" ) parser.add_argument( "--batch_size", type=int, default=8, help="Batch size for model forward passes" ) parser.add_argument("--device", type=str, default=None, help="Device (e.g. cuda:0)") parser.add_argument( "--dtype", type=str, default="bfloat16" if torch.cuda.is_available() else "float32", help="Torch dtype", ) parser.add_argument( "--sentinel_text", type=str, default="\n", help='Plain-text prefix for unconditional pass (default: "\\n"). Use "" for bos/pad fallback.', ) parser.add_argument( "--per_token", action="store_true", help="Include per-token IFD breakdown in output", ) parser.add_argument( "--max_samples", type=int, default=None, help="Maximum number of samples per file (random subsample). Default: all.", ) args = parser.parse_args() if args.device is None: args.device = "cuda" if torch.cuda.is_available() else "cpu" dtype = getattr(torch, args.dtype) print(f"Loading model from {args.param_path} ...") model = AutoModel.from_pretrained(args.param_path) tokenizer = AutoTokenizer.from_pretrained(args.param_path) model.to(device=args.device, dtype=dtype) model.eval() sentinel_ids = _resolve_sentinel_ids(tokenizer, args.sentinel_text) input_files = _collect_input_files(args.input_path) if not input_files: print(f"No input files found at {args.input_path}") return print(f"Found {len(input_files)} file(s) to evaluate") os.makedirs(args.output_dir, exist_ok=True) all_stats = {} for filepath in input_files: label = os.path.splitext(os.path.basename(filepath))[0] output_file = os.path.join(args.output_dir, f"{label}_ifd.jsonl") stats = process_file( model=model, tokenizer=tokenizer, input_file=filepath, output_file=output_file, instr_key=args.instr_key, resp_key=args.resp_key, max_len=args.max_len, data_format=args.format, batch_size=args.batch_size, device=args.device, sentinel_ids=sentinel_ids, per_token=args.per_token, max_samples=args.max_samples, ) all_stats[label] = stats summary_path = os.path.join(args.output_dir, "summary.json") with open(summary_path, "w", encoding="utf-8") as f: json.dump(all_stats, f, ensure_ascii=False, indent=2) print(f"\nSummary saved to {summary_path}") if __name__ == "__main__": main()