refactor : 基于声明式 JSON 配置的预处理管线重构

- 用工厂注册的 MaskBuilder(chat/instruction/text)替换硬编码的 _transform_* 方法
- mask 规则以 role-to-action 映射声明在配置中,与 chat_template 完全解耦
- 单次编码 + role-span 追踪替代两次编码 + 长度差计算 mask 的方式
- 支持多轮对话训练:所有 assistant 轮次参与训练,而非仅最后一轮
- 新建 astrai.preprocessing 包(builder.py + pipeline.py),删除 astrai/preprocess.py
- CLI 精简为 --config 参数,所有参数通过 PipelineConfig JSON 配置
- 新增 PipelineConfig、InputConfig、ProcessingConfig、OutputConfig dataclass
- 文档:assets/docs/preprocessing.md
- 27 个测试覆盖 mask builder、pipeline、配置序列化、工厂注册
This commit is contained in:
2026-05-30 20:45:09 +08:00
parent 138c5bcc08
commit 69207e2c57
10 changed files with 1170 additions and 386 deletions
+10 -1
View File
@@ -4,13 +4,22 @@ from astrai.config.model_config import (
ConfigFactory,
EncoderConfig,
)
from astrai.config.preprocess_config import (
InputConfig,
OutputConfig,
PipelineConfig,
ProcessingConfig,
)
from astrai.config.train_config import TrainConfig
__all__ = [
# Model configuration
"BaseModelConfig",
"AutoRegressiveLMConfig",
"EncoderConfig",
"ConfigFactory",
"TrainConfig",
"InputConfig",
"OutputConfig",
"PipelineConfig",
"ProcessingConfig",
]
+88
View File
@@ -0,0 +1,88 @@
"""Pipeline configuration for JSONL preprocessing."""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Dict, Optional
@dataclass
class InputConfig:
type: str = "chat"
messages_key: str = "messages"
prompt_key: str = "prompt"
response_key: str = "response"
text_key: str = "text"
@dataclass
class ProcessingConfig:
max_seq_len: int = 2048
min_chars: int = 50
max_chars: int = 2_000_000
deduplicate: bool = True
max_items: Optional[int] = None
@dataclass
class OutputConfig:
domain_key: Optional[str] = None
storage_format: str = "bin"
max_tokens_per_shard: int = 100_000_000
@dataclass
class PipelineConfig:
version: int = 1
input: InputConfig = field(default_factory=InputConfig)
mask: Dict[str, str] = field(default_factory=dict)
mask_default: str = "mask"
preprocessing: ProcessingConfig = field(default_factory=ProcessingConfig)
output: OutputConfig = field(default_factory=OutputConfig)
def to_dict(self) -> dict:
return {
"version": self.version,
"input": {
"type": self.input.type,
"messages_key": self.input.messages_key,
"prompt_key": self.input.prompt_key,
"response_key": self.input.response_key,
"text_key": self.input.text_key,
},
"mask": self.mask,
"mask_default": self.mask_default,
"preprocessing": {
"max_seq_len": self.preprocessing.max_seq_len,
"min_chars": self.preprocessing.min_chars,
"max_chars": self.preprocessing.max_chars,
"deduplicate": self.preprocessing.deduplicate,
"max_items": self.preprocessing.max_items,
},
"output": {
"domain_key": self.output.domain_key,
"storage_format": self.output.storage_format,
"max_tokens_per_shard": self.output.max_tokens_per_shard,
},
}
@classmethod
def from_dict(cls, data: dict) -> PipelineConfig:
return PipelineConfig(
version=data.get("version", 1),
input=InputConfig(**data.get("input", {})),
mask=data.get("mask", {}),
mask_default=data.get("mask_default", "mask"),
preprocessing=ProcessingConfig(**data.get("preprocessing", {})),
output=OutputConfig(**data.get("output", {})),
)
@classmethod
def from_json(cls, path: str) -> PipelineConfig:
with open(path, "r", encoding="utf-8") as f:
return cls.from_dict(json.load(f))
def to_json(self, path: str):
with open(path, "w", encoding="utf-8") as f:
json.dump(self.to_dict(), f, indent=2, ensure_ascii=False)
-271
View File
@@ -1,271 +0,0 @@
"""Composable pipeline: raw JSONL → tokenized .h5 / .bin.
Auto-detects JSONL format:
- ``messages`` → applies chat template, computes loss_mask
- ``text`` / plain string field → pure tokenize (pretraining)
- ``prompt`` + ``response`` → explicit loss_mask from field boundaries
Override ``Pipeline.transform()`` to add custom filters or format support.
"""
from __future__ import annotations
import hashlib
import json
import os
from collections import defaultdict
from typing import List, Optional
import torch
import tqdm
from astrai.dataset.storage import save_bin, save_h5
from astrai.tokenize import AutoTokenizer
TEXT_KEYS = ["text", "content", "document", "body", "article", "passage"]
DOMAIN_KEYS = ["domain", "source", "category", "topic", "lang", "language"]
MESSAGE_KEYS = ["messages", "conversation", "conversations", "dialog"]
def detect_format(paths: List[str]) -> dict:
"""Auto-detect JSONL schema from first non-empty line.
Returns ``{text_key, domain_key, is_chat}``.
"""
for p in paths:
with open(p, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
obj = json.loads(line)
for k in MESSAGE_KEYS:
if k in obj and isinstance(obj[k], list):
return {
"text_key": k,
"domain_key": _find(obj, DOMAIN_KEYS),
"is_chat": True,
}
tk = _find(obj, TEXT_KEYS)
dk = _find(obj, DOMAIN_KEYS)
return {"text_key": tk or "text", "domain_key": dk, "is_chat": False}
return {"text_key": "text", "domain_key": None, "is_chat": False}
def _find(obj: dict, candidates: List[str]) -> Optional[str]:
for k in candidates:
if k in obj and isinstance(obj[k], str):
return k
for k, v in obj.items():
if isinstance(v, str) and len(v) > 20:
return k
return None
def filter_length(text: str, min_len: int = 50, max_len: int = 2_000_000) -> bool:
return min_len <= len(text) <= max_len
def dedup_signature(item: dict) -> str:
raw = json.dumps(item, sort_keys=True, ensure_ascii=False)
return hashlib.md5(raw[:200].encode()).hexdigest()
class Pipeline:
"""Tokenization pipeline: JSONL → tokenized → .h5/.bin.
Formats handled automatically:
=============== ============================================
JSON keys behaviour
=============== ============================================
``messages`` apply chat template, auto loss_mask
``text`` plain tokenize (sequence only)
``prompt``+``response`` explicit loss_mask
=============== ============================================
Usage::
p = Pipeline(["docs.jsonl"], output_dir="data/train", tokenizer_path="params")
p.run()
"""
def __init__(
self,
input_paths: List[str],
output_dir: str,
tokenizer_path: str,
text_key: Optional[str] = None,
domain_key: Optional[str] = None,
max_len: int = 2048,
min_text_len: int = 50,
max_text_len: int = 2_000_000,
dedup: bool = True,
max_items: Optional[int] = None,
max_tokens_per_shard: int = 100_000_000,
storage_format: str = "bin",
):
os.makedirs(output_dir, exist_ok=True)
self.paths = input_paths
self.output_dir = output_dir
self.tokenizer_path = tokenizer_path
self.max_len = max_len
self.min_text_len = min_text_len
self.max_text_len = max_text_len
self.dedup = dedup
self.max_items = max_items
self.max_tokens_per_shard = max_tokens_per_shard
self.storage_format = storage_format
if text_key or domain_key:
self.text_key = text_key or "text"
self.domain_key = domain_key
self.is_chat = False
else:
fmt = detect_format(input_paths)
self.text_key = fmt["text_key"]
self.domain_key = fmt["domain_key"]
self.is_chat = fmt["is_chat"]
def transform(self, item: dict) -> Optional[dict]:
"""Process one JSONL line → {ids, loss_mask?, domain}.
Override to add custom filters or data formats.
"""
if self.is_chat:
return self._transform_chat(item)
if "prompt" in item and "response" in item:
return self._transform_prompt_response(item)
return self._transform_text(item)
def _transform_text(self, item: dict) -> Optional[dict]:
text = item.get(self.text_key, "")
if not isinstance(text, str) or not text.strip():
return None
if not filter_length(text, self.min_text_len, self.max_text_len):
return None
ids = self._tokenizer.encode(text, add_special_tokens=True)
ids = ids[: self.max_len]
return {"ids": ids, "domain": self._domain(item)}
def _transform_chat(self, item: dict) -> Optional[dict]:
messages = item.get(self.text_key)
if not isinstance(messages, list) or not messages:
return None
def _encode(msgs):
s = self._tokenizer.apply_chat_template(
msgs, tokenize=False, add_generation_prompt=False
)
return s, self._tokenizer.encode(s, add_special_tokens=True)
full_str, full_ids = _encode(messages)
if not filter_length(full_str, self.min_text_len, self.max_text_len):
return None
prompt_msgs = messages[:-1]
if prompt_msgs:
_, prompt_ids = _encode(prompt_msgs)
else:
prompt_ids = []
full_ids = full_ids[: self.max_len]
loss_mask = [0] * min(len(prompt_ids), len(full_ids))
loss_mask += [1] * (len(full_ids) - len(loss_mask))
return {"ids": full_ids, "loss_mask": loss_mask, "domain": self._domain(item)}
def _transform_prompt_response(self, item: dict) -> Optional[dict]:
prompt = str(item.get("prompt", ""))
response = str(item.get("response", ""))
if not prompt.strip() and not response.strip():
return None
p_ids = self._tokenizer.encode(prompt, add_special_tokens=True)
r_ids = self._tokenizer.encode(response, add_special_tokens=False)
full_ids = (p_ids + r_ids)[: self.max_len]
loss_mask = [0] * min(len(p_ids), len(full_ids))
loss_mask += [1] * (len(full_ids) - len(loss_mask))
return {"ids": full_ids, "loss_mask": loss_mask, "domain": self._domain(item)}
def _domain(self, item: dict) -> str:
if not self.domain_key:
return "__default__"
val = item.get(self.domain_key, "__default__")
return val if isinstance(val, str) else "__default__"
def run(self):
self._tokenizer = AutoTokenizer.from_pretrained(self.tokenizer_path)
seen = set()
domains: dict[str, dict[str, list[list[int]]]] = defaultdict(
lambda: defaultdict(list)
)
total_tokens = 0
shard_idx: dict[str, int] = defaultdict(int)
count = 0
for item in tqdm.tqdm(
self._iter_items(), desc="Tokenizing", unit="docs", mininterval=0.5
):
if self.max_items and count >= self.max_items:
break
if self.dedup:
sig = dedup_signature(item)
if sig in seen:
continue
seen.add(sig)
result = self.transform(item)
if result is None:
continue
ids = result["ids"]
if not ids:
continue
domain = result["domain"]
domains[domain]["sequence"].append(ids)
if "loss_mask" in result:
domains[domain]["loss_mask"].append(result["loss_mask"])
count += 1
total_tokens += len(ids)
if total_tokens >= self.max_tokens_per_shard:
self._flush(domains, shard_idx)
domains.clear()
total_tokens = 0
if total_tokens > 0:
self._flush(domains, shard_idx)
print(f"Done. {count} documents tokenized.")
def _iter_items(self):
for path in self.paths:
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
yield json.loads(line)
def _flush(self, domains, shard_idx):
for domain, keys in domains.items():
idx = shard_idx[domain]
tensors = {}
for key, ids_list in keys.items():
tensors[key] = [torch.tensor(sum(ids_list, []), dtype=torch.long)]
chunk_dir = os.path.join(self.output_dir, domain)
if self.storage_format == "bin":
save_bin(chunk_dir, tensors)
else:
save_h5(chunk_dir, f"data_{idx:04d}", tensors)
shard_idx[domain] = idx + 1
tqdm.tqdm.write(
f" saved {domain}/shard_{idx:04d} "
f"({tensors['sequence'][0].numel():,} tokens)"
)
+19
View File
@@ -0,0 +1,19 @@
from astrai.preprocessing.builder import (
BaseMaskBuilder,
ChatMaskBuilder,
InstructionMaskBuilder,
MaskBuilderFactory,
TextMaskBuilder,
)
from astrai.preprocessing.pipeline import Pipeline, dedup_signature, filter_by_length
__all__ = [
"BaseMaskBuilder",
"ChatMaskBuilder",
"InstructionMaskBuilder",
"MaskBuilderFactory",
"TextMaskBuilder",
"Pipeline",
"dedup_signature",
"filter_by_length",
]
+161
View File
@@ -0,0 +1,161 @@
"""Mask building strategies for preprocessing pipeline.
Each builder knows how to tokenize one input format and construct
the loss_mask according to declarative mask rules from the config.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import List, Optional
from astrai.factory import BaseFactory
class BaseMaskBuilder(ABC):
"""Convert a JSONL item into token ids and optional loss_mask."""
@abstractmethod
def build(self, item: dict, config, tokenizer) -> Optional[dict]:
"""Build ``{ids, loss_mask?, domain}`` from a JSONL record.
Returns ``None`` to skip the item entirely.
"""
...
class MaskBuilderFactory(BaseFactory["BaseMaskBuilder"]):
@classmethod
def _validate_component(cls, component_cls: type):
if not issubclass(component_cls, BaseMaskBuilder):
raise TypeError(
f"{component_cls.__name__} must inherit from BaseMaskBuilder"
)
def _extract_domain(item: dict, domain_key: Optional[str]) -> str:
if not domain_key:
return "__default__"
val = item.get(domain_key, "__default__")
return val if isinstance(val, str) else "__default__"
@MaskBuilderFactory.register("chat")
class ChatMaskBuilder(BaseMaskBuilder):
"""Mask by role via message-level tokenisation with role-span tracking.
For each message, renders the chat template for that single message,
encodes individually, and records its token span + role action.
The concatenated sequence receives a loss_mask built from span rules.
"""
def build(self, item: dict, config, tokenizer) -> Optional[dict]:
messages = item.get(config.input.messages_key)
if not isinstance(messages, list) or not messages:
return None
all_ids: List[int] = []
spans: List[tuple] = []
if tokenizer.bos_token_id is not None:
all_ids.append(tokenizer.bos_token_id)
for msg in messages:
role = msg.get("role", "")
action = config.mask.get(role, config.mask_default)
rendered = tokenizer.apply_chat_template(
[msg], tokenize=False, add_generation_prompt=False
)
ids = tokenizer.encode(rendered, add_special_tokens=False)
start = len(all_ids)
all_ids.extend(ids)
spans.append((start, len(all_ids), action))
if len(all_ids) <= 1:
return None
max_len = config.preprocessing.max_seq_len
all_ids = all_ids[:max_len]
loss_mask = [0] * len(all_ids)
for start, end, action in spans:
if start >= len(all_ids):
break
e = min(end, len(all_ids))
if action == "train":
loss_mask[start:e] = [1] * (e - start)
return {
"ids": all_ids,
"loss_mask": loss_mask,
"domain": _extract_domain(item, config.output.domain_key),
}
@MaskBuilderFactory.register("instruction")
class InstructionMaskBuilder(BaseMaskBuilder):
"""Mask by prompt / response field boundary.
Encodes prompt and response independently, then fills mask
according to ``prompt`` / ``response`` entries in the mask config.
"""
def build(self, item: dict, config, tokenizer) -> Optional[dict]:
prompt = str(item.get(config.input.prompt_key, ""))
response = str(item.get(config.input.response_key, ""))
if not prompt.strip() and not response.strip():
return None
prompt_ids = tokenizer.encode(prompt, add_special_tokens=True)
response_ids = tokenizer.encode(response, add_special_tokens=False)
max_len = config.preprocessing.max_seq_len
full_ids = (prompt_ids + response_ids)[:max_len]
prompt_action = config.mask.get("prompt", config.mask_default)
response_action = config.mask.get("response", config.mask_default)
p_len = min(len(prompt_ids), len(full_ids))
r_len = len(full_ids) - p_len
loss_mask = []
if prompt_action == "train":
loss_mask += [1] * p_len
else:
loss_mask += [0] * p_len
if response_action == "train":
loss_mask += [1] * r_len
else:
loss_mask += [0] * r_len
return {
"ids": full_ids,
"loss_mask": loss_mask,
"domain": _extract_domain(item, config.output.domain_key),
}
@MaskBuilderFactory.register("text")
class TextMaskBuilder(BaseMaskBuilder):
"""Plain tokenisation — no mask, used for pre-training data."""
def build(self, item: dict, config, tokenizer) -> Optional[dict]:
text = item.get(config.input.text_key, "")
if not isinstance(text, str) or not text.strip():
return None
pp = config.preprocessing
if not (pp.min_chars <= len(text) <= pp.max_chars):
return None
ids = tokenizer.encode(text, add_special_tokens=True)
ids = ids[: pp.max_seq_len]
return {
"ids": ids,
"domain": _extract_domain(item, config.output.domain_key),
}
+134
View File
@@ -0,0 +1,134 @@
"""Config-driven JSONL preprocessing pipeline.
Composes a :class:`BaseMaskBuilder` (selected by ``input.type``) with
deduplication, sharding, and flush to ``.h5`` / ``.bin`` storage.
"""
from __future__ import annotations
import hashlib
import json
import os
from collections import defaultdict
from typing import List, Optional
import torch
import tqdm
from astrai.config.preprocess_config import PipelineConfig
from astrai.dataset.storage import save_bin, save_h5
from astrai.preprocessing.builder import MaskBuilderFactory
from astrai.tokenize import AutoTokenizer
def filter_by_length(text: str, min_len: int = 50, max_len: int = 2_000_000) -> bool:
return min_len <= len(text) <= max_len
def dedup_signature(item: dict) -> str:
raw = json.dumps(item, sort_keys=True, ensure_ascii=False)
return hashlib.md5(raw[:200].encode()).hexdigest()
class Pipeline:
"""Tokenization pipeline driven by a declarative :class:`PipelineConfig`.
Usage::
config = PipelineConfig.from_json("sft_pipeline.json")
Pipeline(config, ["data.jsonl"], output_dir="out", tokenizer_path="params").run()
"""
def __init__(
self,
config: PipelineConfig,
input_paths: List[str],
output_dir: str,
tokenizer_path: str,
):
os.makedirs(output_dir, exist_ok=True)
self.config = config
self.paths = input_paths
self.output_dir = output_dir
self.tokenizer_path = tokenizer_path
self.mask_builder = MaskBuilderFactory.create(config.input.type)
def transform(self, item: dict) -> Optional[dict]:
return self.mask_builder.build(item, self.config, self._tokenizer)
def run(self):
self._tokenizer = AutoTokenizer.from_pretrained(self.tokenizer_path)
seen: set = set()
domains: dict = defaultdict(lambda: defaultdict(list))
total_tokens = 0
shard_idx: dict[str, int] = defaultdict(int)
count = 0
pp = self.config.preprocessing
for item in tqdm.tqdm(
self._iter_items(), desc="Tokenizing", unit="docs", mininterval=0.5
):
if pp.max_items and count >= pp.max_items:
break
if pp.deduplicate:
sig = dedup_signature(item)
if sig in seen:
continue
seen.add(sig)
result = self.transform(item)
if result is None:
continue
ids = result["ids"]
if not ids:
continue
domain = result.get("domain", "__default__")
domains[domain]["sequence"].append(ids)
if "loss_mask" in result:
domains[domain]["loss_mask"].append(result["loss_mask"])
count += 1
total_tokens += len(ids)
if total_tokens >= self.config.output.max_tokens_per_shard:
self._flush(domains, shard_idx)
domains.clear()
total_tokens = 0
if total_tokens > 0:
self._flush(domains, shard_idx)
print(f"Done. {count} documents tokenized.")
def _iter_items(self):
for path in self.paths:
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
yield json.loads(line)
def _flush(self, domains, shard_idx):
for domain, keys in domains.items():
idx = shard_idx[domain]
tensors = {}
for key, ids_list in keys.items():
tensors[key] = [torch.tensor(sum(ids_list, []), dtype=torch.long)]
chunk_dir = os.path.join(self.output_dir, domain)
fmt = self.config.output.storage_format
if fmt == "bin":
save_bin(chunk_dir, tensors)
else:
save_h5(chunk_dir, f"data_{idx:04d}", tensors)
shard_idx[domain] = idx + 1
tqdm.tqdm.write(
f" saved {domain}/shard_{idx:04d} "
f"({tensors['sequence'][0].numel():,} tokens)"
)