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:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
@@ -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)"
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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)"
|
||||
)
|
||||
Reference in New Issue
Block a user