| import os |
| import json |
| import math |
| import time |
| import csv |
| import logging |
| import argparse |
| from typing import List, Dict, Any, Tuple, Optional |
|
|
| import torch |
| import torch.nn as nn |
| from tqdm import tqdm |
|
|
| from transformers import AutoTokenizer, AutoModel |
| from peft import LoraConfig, get_peft_model |
|
|
| |
| torch.backends.cuda.enable_flash_sdp(True) |
| torch.backends.cuda.enable_mem_efficient_sdp(True) |
| torch.backends.cuda.enable_math_sdp(False) |
|
|
| |
| |
| |
| logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s") |
| logger = logging.getLogger("infer_worker") |
|
|
| |
| |
| |
| SYSTEM_PROMPT = """ |
| Act as an expert in reticular chemistry. You will receive reaction conditions as a JSON object with the fields: metal_precursor, |
| organic_linker, modulator, solvent, metal_concentration_mM, M_L_ratio, temperature_C, and time_h. Based on these inputs, output exactly one uppercase label: |
| 'P' if the conditions are likely to yield a crystalline metal-organic framework under experimental conditions, or 'N' if not.. |
| """ |
|
|
| |
| |
| |
| def load_messages_from_jsonl(path: str) -> List[List[Dict[str, Any]]]: |
| all_messages = [] |
| with open(path, "r", encoding="utf-8-sig") as f: |
| for line in f: |
| line = line.strip() |
| if not line: |
| continue |
| data = json.loads(line) |
| if "messages" in data: |
| all_messages.append(data["messages"]) |
| return all_messages |
|
|
| def standardize_json_input(json_string: str) -> str: |
| try: |
| data = json.loads(json_string) |
| keep = { |
| "metal_precursor": data.get("metal_precursor"), |
| "organic_linker": data.get("organic_linker"), |
| "modulator": data.get("modulator"), |
| "solvent": data.get("solvent"), |
| "metal_concentration_mM": data.get("metal_concentration_mM"), |
| "M_L_ratio": data.get("M_L_ratio"), |
| "temperature_C": data.get("temperature_C"), |
| "time_h": data.get("time_h"), |
| } |
| return json.dumps(keep, ensure_ascii=False) |
| except Exception: |
| return json_string |
|
|
| def char_norm(s: Optional[str]) -> Optional[str]: |
| s = (s or "").strip().upper() |
| return s if s in ("P", "N") else None |
|
|
| def standardize_messages(messages: List[Dict[str, Any]]) -> Tuple[List[Dict[str, str]], Optional[str]]: |
| user_content = None |
| assistant_content = None |
| for m in messages: |
| if m.get("role") == "user": |
| user_content = m.get("content") |
| elif m.get("role") == "assistant": |
| assistant_content = m.get("content") |
|
|
| user_content = standardize_json_input(user_content or "") |
| standardized = [ |
| {"role": "system", "content": SYSTEM_PROMPT}, |
| {"role": "user", "content": user_content}, |
| ] |
| gold = char_norm(assistant_content) |
| return standardized, gold |
|
|
| def get_user_before_last_assistant(messages): |
| if not messages: |
| return None, None |
| last_asst_idx = None |
| for i in range(len(messages) - 1, -1, -1): |
| if messages[i].get("role") == "assistant": |
| last_asst_idx = i |
| break |
| if last_asst_idx is None: |
| return None, None |
| gold_char = char_norm(messages[last_asst_idx].get("content")) |
| user_raw = None |
| for j in range(last_asst_idx - 1, -1, -1): |
| if messages[j].get("role") == "user": |
| user_raw = messages[j].get("content") |
| break |
| return user_raw, gold_char |
|
|
| |
| |
| |
| def build_model_with_lora(base_model_name: str, cache_dir: str, device: torch.device): |
| lora_config = LoraConfig( |
| r=8, |
| lora_alpha=16, |
| target_modules=["q_proj", "v_proj", "o_proj"], |
| lora_dropout=0.01, |
| bias="none", |
| task_type="SEQ_CLS" |
| ) |
|
|
| backbone = AutoModel.from_pretrained( |
| base_model_name, |
| torch_dtype=torch.bfloat16, |
| device_map=None, |
| cache_dir=cache_dir, |
| ).to(device) |
|
|
| backbone = get_peft_model(backbone, lora_config) |
|
|
| class QwenWithLoRAForBinaryClassification(nn.Module): |
| def __init__(self, backbone): |
| super().__init__() |
| self.backbone = backbone |
| self.config = backbone.config |
| hidden_size = backbone.config.hidden_size |
| self.classifier = nn.Linear(hidden_size, 2) |
|
|
| def forward(self, input_ids, attention_mask=None, labels=None): |
| outputs = self.backbone( |
| input_ids=input_ids, |
| attention_mask=attention_mask, |
| return_dict=True, |
| ) |
| mask = attention_mask.unsqueeze(-1) |
| pooled = (outputs.last_hidden_state * mask).sum(1) / mask.sum(1) |
|
|
| if (self.classifier.weight.device != pooled.device) or (self.classifier.weight.dtype != pooled.dtype): |
| self.classifier = self.classifier.to(device=pooled.device, dtype=pooled.dtype) |
|
|
| logits = self.classifier(pooled) |
| loss = None |
| if labels is not None: |
| loss = nn.CrossEntropyLoss()(logits, labels) |
| return {"loss": loss, "logits": logits} |
|
|
| return QwenWithLoRAForBinaryClassification(backbone).to(device) |
|
|
| def load_trained_weights(model: nn.Module, ckpt_dir: str): |
| candidates = [ |
| os.path.join(ckpt_dir, "pytorch_model.bin"), |
| os.path.join(ckpt_dir, "model.safetensors"), |
| os.path.join(ckpt_dir, "adapter_model.bin"), |
| ] |
| weight_path = None |
| for p in candidates: |
| if os.path.isfile(p): |
| weight_path = p |
| break |
| if weight_path is None: |
| raise FileNotFoundError(f"在 {ckpt_dir} 找不到权重文件") |
|
|
| logger.info(f"Loading weights from: {weight_path}") |
| if weight_path.endswith(".safetensors"): |
| from safetensors.torch import load_file |
| state = load_file(weight_path) |
| else: |
| state = torch.load(weight_path, map_location="cpu") |
|
|
| missing, unexpected = model.load_state_dict(state, strict=False) |
| logger.info(f"load_state_dict done. missing={len(missing)} unexpected={len(unexpected)}") |
|
|
| |
| |
| |
| @torch.inference_mode() |
| def predict_batch( |
| model, |
| tokenizer, |
| device: torch.device, |
| input_texts: List[str], |
| max_length: int, |
| ): |
| enc = tokenizer( |
| input_texts, |
| truncation=True, |
| max_length=max_length, |
| padding="longest", |
| return_tensors="pt", |
| ) |
| enc = {k: v.pin_memory().to(device, non_blocking=True) for k, v in enc.items()} |
|
|
| with torch.autocast(device_type="cuda", dtype=torch.bfloat16): |
| out = model(**enc) |
|
|
| logits = out["logits"] if isinstance(out, dict) else out.logits |
| probs = torch.softmax(logits, dim=-1) |
| prob_p = probs[:, 1].detach().float().cpu().tolist() |
| pred_int = probs.argmax(dim=-1).detach().cpu().tolist() |
| return pred_int, prob_p |
|
|
| |
| |
| |
| def load_progress(progress_path: str) -> int: |
| if not os.path.exists(progress_path): |
| return -1 |
| try: |
| with open(progress_path, "r", encoding="utf-8") as f: |
| obj = json.load(f) |
| return int(obj.get("last_done_index", -1)) |
| except Exception: |
| return -1 |
|
|
| def save_progress(progress_path: str, last_done_index: int): |
| tmp = progress_path + ".tmp" |
| with open(tmp, "w", encoding="utf-8") as f: |
| json.dump({"last_done_index": int(last_done_index)}, f, ensure_ascii=False) |
| f.flush() |
| os.fsync(f.fileno()) |
| os.replace(tmp, progress_path) |
|
|
| |
| |
| |
| def main(): |
| ap = argparse.ArgumentParser() |
| ap.add_argument("--base_model_name", default="openai") |
| ap.add_argument("--cache_dir", default=".") |
| ap.add_argument("--ckpt_dir", required=True) |
| ap.add_argument("--data_jsonl", required=True) |
| ap.add_argument("--out_dir", required=True) |
|
|
| ap.add_argument("--batch_size", type=int, default=64) |
| ap.add_argument("--max_length", type=int, default=512) |
|
|
| ap.add_argument("--num_shards", type=int, required=True) |
| ap.add_argument("--shard_id", type=int, required=True) |
|
|
| ap.add_argument("--eval_every_steps", type=int, default=100) |
| ap.add_argument("--flush_every_steps", type=int, default=1) |
| args = ap.parse_args() |
|
|
| os.makedirs(args.out_dir, exist_ok=True) |
|
|
| if not torch.cuda.is_available(): |
| raise RuntimeError("CUDA not available: multi-GPU inference requires GPU") |
| device = torch.device("cuda") |
|
|
| |
| tokenizer = AutoTokenizer.from_pretrained( |
| args.base_model_name, |
| trust_remote_code=True, |
| cache_dir=args.cache_dir, |
| local_files_only=False, |
| enable_thinking=False, |
| ) |
| if tokenizer.pad_token is None: |
| tokenizer.pad_token = tokenizer.eos_token |
|
|
| def apply_chat_template(messages: List[Dict[str, str]]) -> str: |
| return tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False) |
|
|
| |
| logger.info(f"[shard {args.shard_id}/{args.num_shards}] Loading data...") |
| messages_all = load_messages_from_jsonl(args.data_jsonl) |
| n = len(messages_all) |
| logger.info(f"[shard {args.shard_id}] total samples={n}") |
|
|
| |
| shard_tag = f"shard{args.shard_id:03d}_of_{args.num_shards:03d}" |
| pred_all_path = os.path.join(args.out_dir, f"preds_all_{shard_tag}.jsonl") |
| pred_err_path = os.path.join(args.out_dir, f"preds_errors_{shard_tag}.jsonl") |
| table_csv_path = os.path.join(args.out_dir, f"preds_table_{shard_tag}.csv") |
| progress_path = os.path.join(args.out_dir, f"progress_{shard_tag}.json") |
|
|
| |
| last_done = load_progress(progress_path) |
| logger.info(f"[shard {args.shard_id}] resume last_done_index={last_done}") |
|
|
| |
| f_all = open(pred_all_path, "a", encoding="utf-8") |
| f_err = open(pred_err_path, "a", encoding="utf-8") |
|
|
| |
| need_header = not os.path.exists(table_csv_path) or os.path.getsize(table_csv_path) == 0 |
| f_csv = open(table_csv_path, "a", encoding="utf-8-sig", newline="") |
| fieldnames = [ |
| "index", |
| "gold", |
| "pred", |
| "pN", |
| "pP", |
| "prob_P", |
| "user_raw", |
| "input_text", |
| ] |
| w = csv.DictWriter(f_csv, fieldnames=fieldnames) |
| if need_header: |
| w.writeheader() |
| f_csv.flush() |
| os.fsync(f_csv.fileno()) |
|
|
| |
| logger.info(f"[shard {args.shard_id}] Building model on {device}...") |
| model = build_model_with_lora(args.base_model_name, args.cache_dir, device) |
| model.eval() |
| logger.info(f"[shard {args.shard_id}] Loading ckpt...") |
| load_trained_weights(model, args.ckpt_dir) |
| logger.info(f"[shard {args.shard_id}] ready.") |
|
|
| |
| gold_seen = 0 |
| correct = 0 |
|
|
| |
| def belongs(i: int) -> bool: |
| return (i % args.num_shards) == args.shard_id and i > last_done |
|
|
| indices = [i for i in range(n) if belongs(i)] |
| total_local = len(indices) |
| steps = math.ceil(total_local / args.batch_size) |
| logger.info(f"[shard {args.shard_id}] local_samples={total_local}, steps={steps}, bs={args.batch_size}") |
|
|
| try: |
| pbar = tqdm(range(steps), desc=f"Infer {shard_tag}") |
| for step in pbar: |
| batch_idxs = indices[step * args.batch_size: (step + 1) * args.batch_size] |
|
|
| |
| input_texts = [] |
| user_raws = [] |
| golds = [] |
|
|
| for orig_i in batch_idxs: |
| msgs = messages_all[orig_i] |
| user_raw, gold_raw = get_user_before_last_assistant(msgs) |
|
|
| |
| std_msgs, gold_std = standardize_messages(msgs) |
| gold = gold_std if gold_std else gold_raw |
|
|
| input_text = apply_chat_template(std_msgs) |
|
|
| input_texts.append(input_text) |
| user_raws.append(user_raw if user_raw is not None else "") |
| golds.append(gold) |
|
|
| pred_int, prob_p_list = predict_batch( |
| model=model, |
| tokenizer=tokenizer, |
| device=device, |
| input_texts=input_texts, |
| max_length=args.max_length, |
| ) |
|
|
| |
| for j, orig_i in enumerate(batch_idxs): |
| pred = "P" if int(pred_int[j]) == 1 else "N" |
| prob_p = float(prob_p_list[j]) |
| user_raw = user_raws[j] |
| gold = golds[j] |
|
|
| |
| |
| row = { |
| "index": orig_i, |
| "gold": gold, |
| "pred": pred, |
| "pN": f"{(1.0 - prob_p):.8f}", |
| "pP": f"{prob_p:.8f}", |
| "prob_P": f"{prob_p:.8f}", |
| "user_raw": user_raw, |
| "input_text": input_texts[j], |
| } |
| w.writerow(row) |
|
|
| |
| if gold in ("P", "N"): |
| item = { |
| "index": orig_i, |
| "gold": gold, |
| "pred": pred, |
| "prob_P": prob_p, |
| "user_raw": user_raw, |
| "input_text": input_texts[j], |
| } |
| f_all.write(json.dumps(item, ensure_ascii=False) + "\n") |
| if pred != gold: |
| f_err.write(json.dumps(item, ensure_ascii=False) + "\n") |
|
|
| gold_seen += 1 |
| if pred == gold: |
| correct += 1 |
|
|
| |
| save_progress(progress_path, max(batch_idxs)) |
|
|
| |
| if (step + 1) % args.flush_every_steps == 0: |
| f_all.flush(); os.fsync(f_all.fileno()) |
| f_err.flush(); os.fsync(f_err.fileno()) |
| f_csv.flush(); os.fsync(f_csv.fileno()) |
|
|
| |
| if (step + 1) % args.eval_every_steps == 0: |
| if gold_seen > 0: |
| acc = correct / gold_seen |
| logger.info(f"[{shard_tag} step {step+1}/{steps}] running_acc={acc:.6f} (gold_seen={gold_seen})") |
| else: |
| logger.info(f"[{shard_tag} step {step+1}/{steps}] running_acc=N/A (no gold)") |
|
|
| finally: |
| try: |
| f_all.close() |
| except Exception: |
| pass |
| try: |
| f_err.close() |
| except Exception: |
| pass |
| try: |
| f_csv.close() |
| except Exception: |
| pass |
|
|
| logger.info(f"[shard {args.shard_id}] DONE. outputs: {pred_all_path} {pred_err_path} {table_csv_path}") |
|
|
| if __name__ == "__main__": |
| main() |
|
|