laya-ad / code /scripts /clef_teacher.py
x6nux's picture
laya-ad v1.1:六精度权重 + 模型卡 + 项目代码快照
71dabc7 verified
Raw History Blame Contribute Delete
26.4 kB
#!/usr/bin/env python3
"""Clef-Flash teacher 接入(RTX 5060 Ti 16GB · 4-bit nf4)。
模型入口是仓库自带的 ``models/clef-flash/joint_schema_model.py``(Qwen3.5-9B 视觉骨干 +
joint schema head)。脚本提供两条路径:
1. 用 bnb nf4 4-bit 在**单卡**上加载 teacher,并把 joint head 留在 bf16;
2. ``--smoke``:一张 224x224 随机图 + 一句中文 state,问一道 choice 题,打印概率后 exit 0;
3. ``--label-all``:流式读 ``data/processed/channel/messages.jsonl``,把每条文本(及其已落盘
图片)交给 teacher,问一道 ``{"ad","normal"}`` 的 choice 题,写
``data/processed/teacher/channel_labels.jsonl``;每 500 条 flush 一次,重跑按 message_id 续跑。
用法::
.venv/Scripts/python.exe scripts/clef_teacher.py --smoke
.venv/Scripts/python.exe scripts/clef_teacher.py --label-all data/processed/channel/messages.jsonl
为什么 lm_head 必须跳过量化
---------------------------
``JointSchemaHead.forward`` 用 backbone 的输出词嵌入做 lexical 特征:
``output_embedding_weight[token_ids].mean(dim=0)``(joint_schema_model.py:382)。
bnb 4-bit 的 ``Params4bit`` 不支持按 token id 索引,量化后这条路径会直接报错/取到错值,
所以 ``llm_int8_skip_modules=["lm_head"]`` 让 lm_head 保持 bf16。
"""
from __future__ import annotations
import os
# 必须在第一次 CUDA 分配之前设置:expandable_segments 降低长序列激活的碎片化,
# 16GB 卡上 4-bit 权重(~5GB)之后剩余空间由激活独占。Windows 分配器不支持该选项。
if os.name != "nt":
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
import argparse
import contextlib
import json
import sys
import time
from pathlib import Path
from typing import Any
import torch
REPO_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_MODEL_DIR = REPO_ROOT / "models" / "clef-flash"
DEFAULT_MESSAGES = REPO_ROOT / "data" / "processed" / "channel" / "messages.jsonl"
DEFAULT_LABELS = REPO_ROOT / "data" / "processed" / "teacher" / "channel_labels.jsonl"
# 602 条消息文本为空(其中 546 条连图片都没有)。空 state 会让 encode_record 产不出
# state token,这里给一个显式占位串,保持"无文本"这件事本身可见。
EMPTY_TEXT_STATE = "(无文本)"
LABEL_QUESTION_ID = "is_ad"
LABEL_INSTRUCTIONS = "这条频道帖子的内容是在做广告或引流推广吗?"
LABEL_CRITERIA = {
"ad": "帖子在推广、引流、售卖商品,或诱导关注其他账号/平台",
"normal": "帖子是正常的内容分享,没有广告或引流意图",
}
# 加载完成后至少要留出的空闲显存(GB)。低于该值说明激活空间不够,smoke 直接拒绝跑。
MIN_FREE_VRAM_GB = 2.0
# 加载前的最低空闲显存门槛(GB):4-bit 权重 + 视觉塔 + head + 激活的最保守估计。
MIN_START_VRAM_GB = 7.0
def _gib(value: int | float) -> float:
return float(value) / 1024**3
def _vram_line(tag: str, device: torch.device) -> float:
free, total = torch.cuda.mem_get_info(device)
allocated = torch.cuda.memory_allocated(device)
reserved = torch.cuda.memory_reserved(device)
print(
f"[vram:{tag}] total={_gib(total):.2f}GB allocated={_gib(allocated):.2f}GB "
f"reserved={_gib(reserved):.2f}GB free={_gib(free):.2f}GB"
)
return _gib(free)
def _resolve_device(device: str) -> torch.device:
parsed = torch.device(device)
if parsed.type != "cuda":
raise SystemExit(f"--device 必须是单张 CUDA 卡(收到 {device!r});本脚本不做 CPU/多卡路径")
if not torch.cuda.is_available():
raise SystemExit("torch.cuda.is_available() == False:当前解释器没有可用的 CUDA 设备")
index = 0 if parsed.index is None else parsed.index
if index >= torch.cuda.device_count():
raise SystemExit(f"--device={device} 超出可见卡数 {torch.cuda.device_count()}")
return torch.device(f"cuda:{index}")
def _import_release_module(model_dir: Path):
"""按仓库约定把模型目录插进 sys.path 再 import 发布包里的入口函数。"""
if not (model_dir / "joint_schema_model.py").is_file():
raise SystemExit(f"找不到模型入口: {model_dir / 'joint_schema_model.py'}")
entry = str(model_dir)
if entry not in sys.path:
sys.path.insert(0, entry)
import joint_schema_model as release
for name in ("load_release_model", "encode_record", "collate_records"):
if not hasattr(release, name):
raise SystemExit(f"{model_dir / 'joint_schema_model.py'} 缺少 {name}()")
return release
def _build_image_only_processor(model_dir: Path):
"""构造只含 image processor + tokenizer 的 Qwen3VLProcessor。
transformers 5.20 的 ``AutoProcessor.from_pretrained`` 会无条件实例化
``Qwen3VLVideoProcessor``,而该类模块顶部 ``import torchvision``;本机没装
torchvision 时整条加载链会 ImportError。smoke 只走图像路径,因此这里显式组一个
video_processor 缺省实现:真正传入 videos 时立即报错,绝不静默降级。
"""
from transformers import AutoImageProcessor, AutoTokenizer
from transformers.models.qwen3_vl.processing_qwen3_vl import Qwen3VLProcessor
from transformers.video_processing_utils import VideoProcessorMixin
# transformers 5.20.2 的 ProcessorMixin.check_argument_for_proper_class 用
# get_possibly_dynamic_module("VideoProcessorMixin") 解析基类,但该名字没有导出到
# transformers 顶层,回退路径又会踩 `_extra_content` 缺失的 AttributeError。
# 这里把类补到顶层,让 isinstance 校验能正常通过(不改库文件)。
import transformers
if not hasattr(transformers, "VideoProcessorMixin"):
transformers.VideoProcessorMixin = VideoProcessorMixin
class _UnavailableVideoProcessor(VideoProcessorMixin):
model_input_names = ["pixel_values_videos"]
def __call__(self, *_args, **_kwargs):
raise RuntimeError(
"torchvision 未安装:本脚本只接入图像输入,视频路径不可用"
"(安装 torchvision 后重跑即可使用官方 processor)"
)
image_processor = AutoImageProcessor.from_pretrained(str(model_dir))
tokenizer = AutoTokenizer.from_pretrained(str(model_dir))
return Qwen3VLProcessor(
image_processor=image_processor,
tokenizer=tokenizer,
video_processor=_UnavailableVideoProcessor(),
)
@contextlib.contextmanager
def _patched_auto_processor(processor):
"""让 ``load_release_model`` 内部的 AutoProcessor.from_pretrained 返回现成 processor。"""
from transformers import AutoProcessor
saved = AutoProcessor.__dict__.get("from_pretrained")
AutoProcessor.from_pretrained = classmethod(lambda cls, *args, **kwargs: processor)
try:
yield
finally:
if saved is None:
del AutoProcessor.from_pretrained
else:
setattr(AutoProcessor, "from_pretrained", saved)
def _load_processor(model_dir: Path) -> tuple[object, bool, str]:
"""优先用官方 AutoProcessor;缺 torchvision 时退化为 image-only 构造。"""
from transformers import AutoProcessor
try:
processor = AutoProcessor.from_pretrained(str(model_dir))
return processor, False, "official"
except ImportError as error:
if "torchvision" not in str(error).lower():
raise
return _build_image_only_processor(model_dir), True, "image_only(torchvision missing)"
def _build_quant_config(dtype: torch.dtype):
from transformers import BitsAndBytesConfig
return BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=dtype,
bnb_4bit_use_double_quant=True,
# 见模块 docstring:joint head 拿 lm_head 权重按 token id 查表,必须保持浮点。
llm_int8_skip_modules=["lm_head"],
)
def verify_quantization(model, dtype: torch.dtype = torch.bfloat16) -> int:
"""确认真的走了 4-bit,且 lm_head/head 没被误量化。返回 Linear4bit 层数。"""
from bitsandbytes.nn import Linear4bit, Params4bit
backbone = model.language_model
quantized_layers = sum(1 for module in backbone.modules() if isinstance(module, Linear4bit))
if quantized_layers == 0:
raise SystemExit("没有任何 Linear 被量化成 4-bit:检查 bitsandbytes 与 transformers 版本")
lm_head = backbone.get_output_embeddings()
weight = lm_head.weight
if isinstance(weight, Params4bit):
raise SystemExit(
"lm_head 被 4-bit 量化了:joint head 的 lexical 路径会失败(joint_schema_model.py:382)"
)
head_dtype = next(model.head.parameters()).dtype
sample_layer = next(m for m in backbone.modules() if isinstance(m, Linear4bit))
quant_state = getattr(sample_layer.weight, "quant_state", None)
quant_type = getattr(quant_state, "quant_type", None)
print(
f"[load] Linear4bit={quantized_layers} quant_type={quant_type} "
f"lm_head_dtype={weight.dtype} head_dtype={head_dtype} "
f"head_device={next(model.head.parameters()).device}"
)
if str(quant_type).lower() != "nf4":
raise SystemExit(f"4-bit 量化类型不是 nf4(实际 {quant_type!r}),检查 BitsAndBytesConfig")
if head_dtype != dtype:
raise SystemExit(f"joint head 期望 {dtype},实际 {head_dtype}")
return quantized_layers
def load_teacher(
model_dir: Path,
device: str = "cuda:0",
dtype: torch.dtype = torch.bfloat16,
) -> tuple[object, object, object]:
"""4-bit nf4 加载 teacher,返回 (release 模块, model, processor)。"""
target = _resolve_device(device)
torch.cuda.set_device(target)
free_before = _gib(torch.cuda.mem_get_info(target)[0])
print(f"[load] device={target} free_before={free_before:.2f}GB model_dir={model_dir}")
if free_before < MIN_START_VRAM_GB:
raise SystemExit(
f"加载前空闲显存只有 {free_before:.2f}GB,低于 {MIN_START_VRAM_GB}GB 门槛;"
"先释放占用显存的进程再试"
)
release = _import_release_module(model_dir)
processor, use_fallback, note = _load_processor(model_dir)
print(f"[load] processor={type(processor).__name__} source={note}")
if use_fallback:
with _patched_auto_processor(processor):
model, loaded_processor = release.load_release_model(
model_dir,
device=str(target),
dtype=dtype,
quantization_config=_build_quant_config(dtype),
)
if loaded_processor is not processor:
raise SystemExit(
f"processor 注入失败:load_release_model 返回了 {type(loaded_processor).__name__}"
)
else:
model, processor = release.load_release_model(
model_dir,
device=str(target),
dtype=dtype,
quantization_config=_build_quant_config(dtype),
)
model.eval()
verify_quantization(model, dtype)
free_after = _vram_line("after_load", target)
if free_after < MIN_FREE_VRAM_GB:
raise SystemExit(
f"加载后只剩 {free_after:.2f}GB 空闲显存,低于激活所需的 {MIN_FREE_VRAM_GB}GB 余量"
)
return release, model, processor
def build_smoke_record(seed: int) -> tuple[dict, object]:
"""一张 224x224 随机图 + 一句中文 state + 一道三选一 choice 题。"""
import numpy as np
from PIL import Image
rng = np.random.default_rng(seed)
pixels = rng.integers(0, 256, size=(224, 224, 3), dtype=np.uint8)
image = Image.fromarray(pixels, mode="RGB")
record = {
"id": "smoke",
"state": "客服工单:用户反馈订单 A1024 在支付页面连续三次失败,要求今天内给出处理方案。",
"images": [image],
"questions": {
"team": {
"type": "choice",
"instructions": "根据状态和图片,这张工单应该交给哪个团队处理?",
"criteria": {
"billing": "支付、退款、账单相关的问题",
"technical": "程序缺陷、服务中断或报错",
"general": "一般咨询、功能介绍或账户操作",
},
}
},
}
return record, image
def run_smoke(
release,
model,
processor,
max_length: int = 4096,
seed: int = 0,
) -> int:
device = next(model.parameters()).device
record, image = build_smoke_record(seed)
print(f"[smoke] image={image.size[0]}x{image.size[1]} mode={image.mode} seed={seed}")
encoded = release.encode_record(
processor.tokenizer,
record,
max_length=max_length,
processor=processor,
)
print(
f"[smoke] input_tokens={len(encoded.input_ids)} questions={len(encoded.questions)} "
f"media={sorted((encoded.media or {}).keys())}"
)
batch = release.collate_records([encoded], processor.tokenizer.pad_token_id, device)
with torch.inference_mode():
logits = model(batch)[0]
exit_code = 0
for question, question_logits in zip(encoded.questions, logits):
probabilities = question_logits.float().softmax(-1)
if not torch.isfinite(probabilities).all():
print(f"[smoke] FAIL: {question.question_id} 概率含 NaN/Inf", file=sys.stderr)
exit_code = 1
continue
pairs = sorted(zip(question.option_ids, probabilities.tolist()), key=lambda kv: -kv[1])
print(f"[smoke] question={question.question_id} options={len(pairs)}")
for option_id, probability in pairs:
print(f" {option_id:>10s} p={probability:.6f}")
best_option, best_probability = pairs[0]
total = sum(probability for _, probability in pairs)
print(
f"[smoke] argmax={best_option} p={best_probability:.6f} sum={total:.6f} "
f"logits={[round(x, 4) for x in question_logits.float().tolist()]}"
)
if abs(total - 1.0) > 1e-4:
print(f"[smoke] FAIL: 概率和 {total:.6f} 偏离 1", file=sys.stderr)
exit_code = 1
_vram_line("after_smoke", device)
print(f"[smoke] peak_allocated={_gib(torch.cuda.max_memory_allocated(device)):.2f}GB")
return exit_code
def _count_lines(path: Path) -> int:
with path.open("rb") as handle:
return sum(1 for _ in handle)
def _load_done_ids(path: Path) -> set[str]:
"""读已有输出里的 message_id;顺手截掉崩溃留下的半行。"""
if not path.exists():
return set()
raw = path.read_text(encoding="utf-8")
if raw and not raw.endswith("\n"):
cut = raw.rfind("\n") + 1
path.write_text(raw[:cut], encoding="utf-8")
raw = raw[:cut]
done: set[str] = set()
for line in raw.splitlines():
line = line.strip()
if not line:
continue
try:
done.add(str(json.loads(line)["message_id"]))
except (ValueError, KeyError):
continue
return done
def _iter_messages(path: Path):
"""逐行流式读取,避免把 10k 条消息一次性放进内存。"""
with path.open("r", encoding="utf-8") as handle:
for line in handle:
line = line.strip()
if line:
yield json.loads(line)
def _build_label_record(row: dict[str, Any]) -> tuple[dict[str, Any], int, bool]:
"""消息 → Clef record。返回 (record, 成功读入的图片数, 是否声明了图片)。"""
from PIL import Image
text = (row.get("text") or "").strip()
paths = [str(path) for path in (row.get("images") or [])]
images = []
for path in paths:
with Image.open(path) as image: # copy 出来,避免文件句柄跟着 PIL 对象悬空
images.append(image.convert("RGB").copy())
record = {
"id": str(row.get("message_id")),
"state": text or EMPTY_TEXT_STATE,
"images": images,
"questions": {
LABEL_QUESTION_ID: {
"type": "choice",
"instructions": LABEL_INSTRUCTIONS,
"criteria": dict(LABEL_CRITERIA),
}
},
}
return record, len(images), bool(paths)
def _error_row(row: dict[str, Any], has_image: bool, error: str) -> dict[str, Any]:
return {
"message_id": row.get("message_id"),
"has_image": has_image,
"error": error,
"date": row.get("date"),
}
def _forward_encoded(release, model, processor, encoded, device) -> list[dict[str, float]]:
batch = release.collate_records(encoded, processor.tokenizer.pad_token_id, device)
with torch.inference_mode():
logits = model(batch)
probabilities: list[dict[str, float]] = []
for encoding, record_logits in zip(encoded, logits):
per_question: dict[str, float] = {}
for question, question_logits in zip(encoding.questions, record_logits):
values = question_logits.float().softmax(-1).tolist()
per_question.update(zip(question.option_ids, values))
probabilities.append(per_question)
return probabilities
def _forward_encoded_safe(release, model, processor, encoded, device) -> list[tuple[dict | None, str | None]]:
"""单批前向;OOM / 批级异常时二分重试,把坏样本隔离到单条。"""
try:
return [(probabilities, None) for probabilities in _forward_encoded(release, model, processor, encoded, device)]
except torch.OutOfMemoryError as error:
torch.cuda.empty_cache()
if len(encoded) == 1:
return [(None, f"cuda_oom: {error}")]
except Exception as error: # noqa: BLE001 - 批级失败先二分,单条再抛出
if len(encoded) == 1:
return [(None, f"{type(error).__name__}: {error}")]
midpoint = max(1, len(encoded) // 2)
return _forward_encoded_safe(
release, model, processor, encoded[:midpoint], device
) + _forward_encoded_safe(release, model, processor, encoded[midpoint:], device)
def run_label_all(release, model, processor, args) -> int:
"""批量打标:messages.jsonl → channel_labels.jsonl(流式 + 每 500 条 flush + 断点续跑)。"""
input_path = Path(args.label_all).expanduser().resolve()
output_path = Path(args.output).expanduser().resolve()
errors_path = output_path.with_suffix(".errors.jsonl")
if not input_path.is_file():
raise SystemExit(f"--label-all 输入不存在: {input_path}")
output_path.parent.mkdir(parents=True, exist_ok=True)
device = next(model.parameters()).device
total = _count_lines(input_path)
done = set() if args.no_resume else _load_done_ids(output_path)
target = max(0, total - len(done))
if args.limit is not None:
target = min(target, args.limit)
print(
f"[label-all] input={input_path} lines={total} skip_done={len(done)} target={target} "
f"output={output_path} batch={args.batch_size} flush_every={args.flush_every}"
)
rows: list[dict] = []
error_rows: list[dict] = []
counters = {"written": 0, "processed": 0, "errors": 0, "skipped": 0}
started = time.monotonic()
def flush():
if rows:
with output_path.open("a", encoding="utf-8") as handle:
handle.write("".join(json.dumps(row, ensure_ascii=False) + "\n" for row in rows))
handle.flush()
os.fsync(handle.fileno())
counters["written"] += len(rows)
rows.clear()
if error_rows:
with errors_path.open("a", encoding="utf-8") as handle:
handle.write("".join(json.dumps(row, ensure_ascii=False) + "\n" for row in error_rows))
handle.flush()
os.fsync(handle.fileno())
error_rows.clear()
elapsed = time.monotonic() - started
rate = counters["processed"] / elapsed if elapsed > 0 else 0.0
remaining = max(0, target - counters["processed"]) / rate / 60 if rate > 0 else float("nan")
print(
f"[label-all] {counters['processed']}/{target} written={counters['written']} "
f"errors={counters['errors']} {rate:.2f} rows/s elapsed={elapsed / 60:.1f}m eta={remaining:.1f}m"
)
def process_batch(items: list[tuple]) -> None:
encoded, kept = [], []
for row, record, has_image, images_used in items:
try:
encoding = release.encode_record(
processor.tokenizer,
record,
max_length=args.max_length,
processor=processor,
)
except Exception as error: # noqa: BLE001 - 坏样本进 errors,不中断整批
counters["errors"] += 1
error_rows.append(_error_row(row, has_image, f"encode {type(error).__name__}: {error}"))
continue
encoded.append(encoding)
kept.append((row, has_image, images_used))
if encoded:
for (row, has_image, images_used), (option_probs, error) in zip(
kept, _forward_encoded_safe(release, model, processor, encoded, device)
):
if option_probs is None:
counters["errors"] += 1
error_rows.append(_error_row(row, has_image, error or "forward failed"))
continue
p_ad = float(option_probs["ad"])
p_normal = float(option_probs["normal"])
rows.append(
{
"message_id": row.get("message_id"),
"p_ad": round(p_ad, 6),
"label": "ad" if p_ad >= p_normal else "normal",
"has_image": has_image,
"p_normal": round(p_normal, 6),
"images_used": images_used,
"date": row.get("date"),
"iso_time": row.get("iso_time"),
}
)
batch_items: list[tuple] = []
for row in _iter_messages(input_path):
message_id = str(row.get("message_id"))
if message_id in done:
counters["skipped"] += 1
continue
if args.limit is not None and counters["processed"] + len(batch_items) >= args.limit:
break
try:
record, images_used, has_image = _build_label_record(row)
except Exception as error: # noqa: BLE001 - 单条图片/构造失败进 errors
counters["errors"] += 1
counters["processed"] += 1
error_rows.append(_error_row(row, bool(row.get("images")), f"build {type(error).__name__}: {error}"))
continue
batch_items.append((row, record, has_image, images_used))
if len(batch_items) >= args.batch_size:
process_batch(batch_items)
counters["processed"] += len(batch_items)
batch_items.clear()
if len(rows) + len(error_rows) >= args.flush_every:
flush()
if batch_items:
process_batch(batch_items)
counters["processed"] += len(batch_items)
flush()
elapsed = time.monotonic() - started
output_lines = _count_lines(output_path) if output_path.exists() else 0
error_lines = _count_lines(errors_path) if errors_path.exists() else 0
print(
f"[label-all] DONE processed={counters['processed']} written_this_run={counters['written']} "
f"skip_done={counters['skipped']} errors={counters['errors']} elapsed={elapsed / 60:.1f}m"
)
print(f"[label-all] output={output_path} lines={output_lines} error_file={errors_path} error_lines={error_lines}")
return 0 if counters["errors"] == 0 else 1
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Clef-Flash teacher:4-bit nf4 单卡加载 + smoke 自检",
)
parser.add_argument(
"--model-dir",
type=Path,
default=DEFAULT_MODEL_DIR,
help=f"模型目录(默认 {DEFAULT_MODEL_DIR})",
)
parser.add_argument("--device", default="cuda:0", help="单卡设备,默认 cuda:0")
parser.add_argument(
"--max-length",
type=int,
default=4096,
help="encode_record 的 token 上限(默认 4096,给 16GB 卡的激活留余量)",
)
parser.add_argument("--seed", type=int, default=0, help="随机图种子(默认 0)")
mode = parser.add_mutually_exclusive_group(required=True)
mode.add_argument("--smoke", action="store_true", help="跑一张随机图 + 中文 state 的 choice 题")
mode.add_argument(
"--label-all",
metavar="INPUT",
default=None,
help=f"批量打标:读 messages.jsonl(默认 {DEFAULT_MESSAGES}),写 channel_labels.jsonl",
)
parser.add_argument(
"--output",
type=Path,
default=DEFAULT_LABELS,
help=f"--label-all 输出路径(默认 {DEFAULT_LABELS})",
)
parser.add_argument("--batch-size", type=int, default=8, help="每次前向的记录数(默认 8)")
parser.add_argument("--flush-every", type=int, default=500, help="每多少条落盘一次(默认 500)")
parser.add_argument("--limit", type=int, default=None, help="只处理前 N 条(调试用,默认全部)")
parser.add_argument(
"--no-resume",
action="store_true",
help="不续跑:忽略输出文件里已有的 message_id(注意输出仍是追加写,需先自行删除旧文件)",
)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
model_dir = args.model_dir.expanduser().resolve()
release, model, processor = load_teacher(model_dir, device=args.device)
if args.smoke:
return run_smoke(release, model, processor, max_length=args.max_length, seed=args.seed)
return run_label_all(release, model, processor, args)
if __name__ == "__main__":
sys.exit(main())