cwenzi/neuroflow-cpp
1
1"""
2preprocess_corpus.py — 语料预处理:文本 → TOK1 二进制格式
3
4TOK1 格式规范:
5 Magic: 4 bytes "TOK1"
6 Version: 2 bytes (uint16 LE) = 1
7 Header: 4 bytes (uint32 LE) = tokenizer_vocab_size
8 4 bytes (uint32 LE) = max_seq_len
9 4 bytes (uint32 LE) = total_sample_count (占位,写完回填)
10 Samples: [2 bytes seq_len (uint16 LE)] [seq_len * 4 bytes token_ids (uint32 LE each)] ...
11 Footer: 4 bytes "END\0"
12
13支持输入格式:txt, json, jsonl, csv, 目录递归
14"""
15
16import os
17import sys
18import json
19import struct
20import argparse
21import logging
22from pathlib import Path
23from collections.abc import Iterator
24
25logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
26logger = logging.getLogger(__name__)
27
28MAGIC = b'TOK1'
29VERSION = 1
30FOOTER = b'END\x00'
31
32SPECIAL_TOKENS = {"<pad>": 0, "<s>": 1, "</s>": 2, "<unk>": 3}
33
34
35class SimpleBPETokenizer:
36 """轻量级BPE分词器,兼容tokenizer_cn_013.json格式"""
37
38 def __init__(self, vocab_path: str):
39 with open(vocab_path, 'r', encoding='utf-8') as f:
40 data = json.load(f)
41
42 if isinstance(data, dict) and 'model' in data:
43 self.vocab = data['model'].get('vocab', {})
44 self.merges = data['model'].get('merges', [])
45 elif isinstance(data, dict) and 'vocab' in data:
46 self.vocab = data['vocab']
47 self.merges = data.get('merges', [])
48 else:
49 self.vocab = data if isinstance(data, dict) else {}
50 self.merges = []
51
52 self.id_to_token = {v: k for k, v in self.vocab.items()}
53 self.vocab_size = len(self.vocab)
54
55 self.merge_ranks = {}
56 for i, merge in enumerate(self.merges):
57 if isinstance(merge, list) and len(merge) == 2:
58 pair = (merge[0], merge[1])
59 elif isinstance(merge, str) and ' ' in merge:
60 parts = merge.split(' ', 1)
61 pair = (parts[0], parts[1])
62 else:
63 continue
64 self.merge_ranks[pair] = i
65
66 logger.info(f"词表加载: vocab_size={self.vocab_size}, merges={len(self.merge_ranks)}")
67
68 def _tokenize_word(self, word: str) -> list:
69 tokens = list(word) if len(word) > 0 else []
70 if not tokens:
71 return tokens
72
73 while len(tokens) >= 2:
74 best_pair = None
75 best_rank = float('inf')
76 for i in range(len(tokens) - 1):
77 pair = (tokens[i], tokens[i + 1])
78 rank = self.merge_ranks.get(pair, float('inf'))
79 if rank < best_rank:
80 best_rank = rank
81 best_pair = pair
82 best_pos = i
83
84 if best_pair is None or best_rank == float('inf'):
85 break
86
87 tokens[best_pos] = best_pair[0] + best_pair[1]
88 tokens.pop(best_pos + 1)
89
90 return tokens
91
92 def encode(self, text: str, max_seq_len: int = 128) -> list:
93 if not text or not text.strip():
94 return []
95
96 text = text.strip()
97 words = []
98 current = []
99 for ch in text:
100 if '\u4e00' <= ch <= '\u9fff':
101 if current:
102 words.append(''.join(current))
103 current = []
104 words.append(ch)
105 elif ch.isspace():
106 if current:
107 words.append(''.join(current))
108 current = []
109 else:
110 current.append(ch)
111 if current:
112 words.append(''.join(current))
113
114 token_ids = [SPECIAL_TOKENS.get("<s>", 1)]
115 for word in words:
116 sub_tokens = self._tokenize_word(word)
117 for st in sub_tokens:
118 tid = self.vocab.get(st, SPECIAL_TOKENS.get("<unk>", 3))
119 token_ids.append(tid)
120 if len(token_ids) >= max_seq_len - 1:
121 break
122 if len(token_ids) >= max_seq_len - 1:
123 break
124
125 token_ids.append(SPECIAL_TOKENS.get("</s>", 2))
126 return token_ids[:max_seq_len]
127
128
129def read_texts_from_json(file_path: str) -> Iterator[str]:
130 """流式读取JSON文件中的文本字段"""
131 with open(file_path, 'r', encoding='utf-8') as f:
132 brace_depth = 0
133 record = ''
134 for line in f:
135 for ch in line:
136 if ch == '{':
137 if brace_depth == 0:
138 record = ''
139 brace_depth += 1
140 elif ch == '}':
141 brace_depth -= 1
142 if brace_depth == 0 and record:
143 record += '}'
144 for text in _extract_texts_from_record(record):
145 yield text
146 record = ''
147 if brace_depth > 0:
148 record += ch
149
150
151def _extract_texts_from_record(record: str) -> Iterator[str]:
152 """从JSON record中提取文本字段"""
153 for field in ['"text"', '"content"', '"title"', '"question"', '"answer"']:
154 pos = record.find(field)
155 if pos == -1:
156 continue
157 colon = record.find(':', pos + len(field))
158 if colon == -1:
159 continue
160 colon += 1
161 while colon < len(record) and record[colon] != '"':
162 colon += 1
163 if colon >= len(record):
164 continue
165 colon += 1
166 end = colon
167 while end < len(record) and record[end] != '"':
168 if record[end] == '\\':
169 end += 1
170 end += 1
171 text = record[colon:end]
172 text = _unescape_json(text)
173 if len(text) >= 10:
174 yield text
175
176
177def _unescape_json(s: str) -> str:
178 return s.replace('\\n', '\n').replace('\\t', '\t').replace('\\"', '"').replace('\\\\', '\\')
179
180
181def read_texts_from_jsonl(file_path: str) -> Iterator[str]:
182 """逐行读取JSONL文件"""
183 with open(file_path, 'r', encoding='utf-8') as f:
184 for line in f:
185 line = line.strip()
186 if not line:
187 continue
188 try:
189 obj = json.loads(line)
190 except json.JSONDecodeError:
191 for text in _extract_texts_from_record(line):
192 yield text
193 continue
194 for field in ['text', 'content', 'title', 'question', 'answer']:
195 if field in obj and isinstance(obj[field], str) and len(obj[field]) >= 10:
196 yield obj[field]
197
198
199def read_texts_from_txt(file_path: str) -> Iterator[str]:
200 """按段落读取纯文本文件"""
201 with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
202 paragraph = ''
203 for line in f:
204 if line.strip() == '':
205 if len(paragraph) >= 10:
206 yield paragraph.strip()
207 paragraph = ''
208 else:
209 paragraph += line
210 if len(paragraph) > 10000:
211 yield paragraph.strip()
212 paragraph = ''
213 if len(paragraph) >= 10:
214 yield paragraph.strip()
215
216
217def read_texts_from_csv(file_path: str) -> Iterator[str]:
218 """读取CSV,第一列作为文本"""
219 with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
220 for line in f:
221 line = line.strip()
222 if not line or line.startswith('#'):
223 continue
224 parts = line.split(',', 1)
225 text = parts[0].strip().strip('"')
226 if len(text) >= 10:
227 yield text
228
229
230def read_texts(file_path: str) -> Iterator[str]:
231 """根据扩展名自动分派读取器"""
232 ext = Path(file_path).suffix.lower()
233 if ext == '.json':
234 return read_texts_from_json(file_path)
235 elif ext == '.jsonl':
236 return read_texts_from_jsonl(file_path)
237 elif ext == '.txt':
238 return read_texts_from_txt(file_path)
239 elif ext in ('.csv', '.tsv'):
240 return read_texts_from_csv(file_path)
241 else:
242 logger.warning(f"跳过不支持的格式: {file_path}")
243 return iter([])
244
245
246def read_texts_recursive(path: str) -> Iterator[tuple]:
247 """递归遍历目录,返回 (文件路径, 文本) 对"""
248 p = Path(path)
249 if p.is_file():
250 for text in read_texts(str(p)):
251 yield (str(p), text)
252 elif p.is_dir():
253 files = sorted(p.rglob('*'))
254 for f in files:
255 if not f.is_file():
256 continue
257 ext = f.suffix.lower()
258 if ext in ('.json', '.jsonl', '.txt', '.csv', '.tsv'):
259 logger.info(f"读取: {f}")
260 try:
261 for text in read_texts(str(f)):
262 yield (str(f), text)
263 except Exception as e:
264 logger.warning(f"跳过 {f}: {e}")
265 else:
266 raise ValueError(f"路径不存在: {path}")
267
268
269def write_tok1(output_path: str, tokenizer: SimpleBPETokenizer,
270 text_iter: Iterator[tuple], max_seq_len: int = 128,
271 max_samples: int = 0, min_tokens: int = 4):
272 """将文本流写入TOK1二进制格式"""
273 total_written = 0
274 total_skipped = 0
275
276 with open(output_path, 'wb') as f:
277 f.write(MAGIC)
278 f.write(struct.pack('<H', VERSION))
279 f.write(struct.pack('<I', tokenizer.vocab_size))
280 f.write(struct.pack('<I', max_seq_len))
281 count_offset = f.tell()
282 f.write(struct.pack('<I', 0))
283
284 for file_path, text in text_iter:
285 token_ids = tokenizer.encode(text, max_seq_len)
286 if len(token_ids) < min_tokens:
287 total_skipped += 1
288 continue
289
290 seq_len = len(token_ids)
291 f.write(struct.pack('<H', seq_len))
292 for tid in token_ids:
293 f.write(struct.pack('<I', tid))
294
295 total_written += 1
296 if total_written % 10000 == 0:
297 logger.info(f"已写入 {total_written} 样本, 跳过 {total_skipped}")
298
299 if max_samples > 0 and total_written >= max_samples:
300 logger.info(f"达到采样上限 {max_samples},停止")
301 break
302
303 f.write(FOOTER)
304
305 f.seek(count_offset)
306 f.write(struct.pack('<I', total_written))
307
308 file_size = os.path.getsize(output_path)
309 logger.info(f"写入完成: {total_written} 样本, 跳过 {total_skipped}")
310 logger.info(f"输出文件: {output_path} ({file_size / 1024 / 1024:.1f} MB)")
311
312
313def split_large_json(input_path: str, output_dir: str, chunk_size: int = 50000):
314 """将大型JSON文件拆分为JSONL分片"""
315 os.makedirs(output_dir, exist_ok=True)
316 chunk_idx = 0
317 count = 0
318 out = None
319
320 for text in read_texts_from_json(input_path):
321 if out is None:
322 chunk_path = os.path.join(output_dir, f'chunk_{chunk_idx:04d}.jsonl')
323 out = open(chunk_path, 'w', encoding='utf-8')
324 logger.info(f"开始分片: {chunk_path}")
325
326 obj = json.dumps({"text": text}, ensure_ascii=False)
327 out.write(obj + '\n')
328 count += 1
329
330 if count >= chunk_size:
331 out.close()
332 logger.info(f"分片 {chunk_idx} 完成: {count} 条")
333 chunk_idx += 1
334 count = 0
335 out = None
336
337 if out:
338 out.close()
339 logger.info(f"分片 {chunk_idx} 完成: {count} 条")
340
341 logger.info(f"拆分完成: {chunk_idx + 1} 个分片")
342
343
344def verify_tok1(file_path: str, max_show: int = 5):
345 """验证TOK1文件格式"""
346 with open(file_path, 'rb') as f:
347 magic = f.read(4)
348 assert magic == MAGIC, f"Magic不匹配: {magic}"
349 version = struct.unpack('<H', f.read(2))[0]
350 vocab_size = struct.unpack('<I', f.read(4))[0]
351 max_seq_len = struct.unpack('<I', f.read(4))[0]
352 total = struct.unpack('<I', f.read(4))[0]
353
354 logger.info(f"TOK1文件: version={version}, vocab_size={vocab_size}, "
355 f"max_seq_len={max_seq_len}, total_samples={total}")
356
357 shown = 0
358 for i in range(total):
359 seq_len = struct.unpack('<H', f.read(2))[0]
360 ids = []
361 for _ in range(seq_len):
362 ids.append(struct.unpack('<I', f.read(4))[0])
363 if shown < max_show:
364 logger.info(f" 样本{i}: len={seq_len}, ids[:10]={ids[:10]}")
365 shown += 1
366
367 footer = f.read(4)
368 assert footer == FOOTER, f"Footer不匹配: {footer}"
369
370 logger.info("验证通过")
371
372
373def main():
374 parser = argparse.ArgumentParser(description='语料预处理: 文本 → TOK1 二进制格式')
375 sub = parser.add_subparsers(dest='command')
376
377 p_process = sub.add_parser('process', help='预处理语料为TOK1格式')
378 p_process.add_argument('--input', required=True, help='输入路径(文件或目录)')
379 p_process.add_argument('--tokenizer', required=True, help='tokenizer JSON路径')
380 p_process.add_argument('--output', required=True, help='输出TOK1文件路径')
381 p_process.add_argument('--max-seq-len', type=int, default=128, help='最大序列长度')
382 p_process.add_argument('--max-samples', type=int, default=0, help='最大样本数(0=不限)')
383 p_process.add_argument('--min-tokens', type=int, default=4, help='最小token数')
384
385 p_split = sub.add_parser('split', help='拆分大型JSON为JSONL分片')
386 p_split.add_argument('--input', required=True, help='输入JSON文件')
387 p_split.add_argument('--output-dir', required=True, help='输出目录')
388 p_split.add_argument('--chunk-size', type=int, default=50000, help='每片条数')
389
390 p_verify = sub.add_parser('verify', help='验证TOK1文件')
391 p_verify.add_argument('--input', required=True, help='TOK1文件路径')
392
393 args = parser.parse_args()
394
395 if args.command == 'process':
396 tokenizer = SimpleBPETokenizer(args.tokenizer)
397 text_iter = read_texts_recursive(args.input)
398 write_tok1(args.output, tokenizer, text_iter,
399 max_seq_len=args.max_seq_len,
400 max_samples=args.max_samples,
401 min_tokens=args.min_tokens)
402 elif args.command == 'split':
403 split_large_json(args.input, args.output_dir, args.chunk_size)
404 elif args.command == 'verify':
405 verify_tok1(args.input)
406 else:
407 parser.print_help()
408
409
410if __name__ == '__main__':
411 main()