Nexuss0781 commited on
Commit
6611338
·
verified ·
1 Parent(s): 9a825bb

Upload scripts/bbpe_trainer.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. scripts/bbpe_trainer.py +515 -71
scripts/bbpe_trainer.py CHANGED
@@ -1,20 +1,32 @@
1
  #!/usr/bin/env python3
2
  """
3
  EthioBBPE: Production-Ready Byte-Level BPE Tokenizer Trainer
4
- Features: Checkpointing, Compression, Parallel Processing, Robust Logging
 
 
 
 
 
 
 
5
  """
6
 
7
  import os
8
  import json
9
  import gzip
 
 
10
  import shutil
 
11
  import logging
12
  from pathlib import Path
13
- from typing import List, Optional, Union, Dict, Any
14
  from dataclasses import dataclass, field, asdict
15
  from datetime import datetime
 
 
16
 
17
- from tokenizers import ByteLevelBPETokenizer, trainers
18
  from tokenizers.implementations import BaseTokenizer
19
 
20
  # Configure logging
@@ -35,11 +47,28 @@ class BBPEConfig:
35
  lowercase: bool = False
36
  dropout: Optional[float] = None
37
 
 
 
 
 
 
38
  # Advanced features
 
 
39
  save_compressed: bool = True
40
- checkpoint_steps: Optional[int] = None # Save checkpoint every N steps if custom trainer used
 
 
41
  num_threads: int = -1 # -1 for auto
42
-
 
 
 
 
 
 
 
 
43
  def save(self, path: str):
44
  """Save configuration to JSON."""
45
  with open(path, 'w', encoding='utf-8') as f:
@@ -54,23 +83,53 @@ class BBPEConfig:
54
  return cls(**data)
55
 
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  class EthioBBPETrainer:
58
  """
59
- Production-ready trainer for Byte-Level BPE with checkpointing and compression.
 
 
 
 
 
60
  """
61
 
62
- def __init__(self, config: BBPEConfig, output_dir: str = "./models"):
63
- self.config = config
64
- self.output_dir = Path(output_dir)
65
- self.checkpoint_dir = self.output_dir / "checkpoints"
66
  self.tokenizer = None
67
  self.is_trained = False
 
 
68
 
69
  # Create directories
70
  self.output_dir.mkdir(parents=True, exist_ok=True)
71
  self.checkpoint_dir.mkdir(parents=True, exist_ok=True)
72
 
73
  logger.info(f"Initialized EthioBBPETrainer with output dir: {self.output_dir}")
 
 
 
74
 
75
  def _initialize_tokenizer(self):
76
  """Initialize the ByteLevelBPETokenizer."""
@@ -81,19 +140,38 @@ class EthioBBPETrainer:
81
  )
82
  logger.info("Tokenizer initialized")
83
 
84
- def train(self, files: Union[str, List[str]], use_checkpoint: bool = False):
85
  """
86
  Train the tokenizer on a list of files or a directory.
87
 
88
  Args:
89
  files: Path to a file, list of files, or directory containing text files.
 
90
  use_checkpoint: If True, attempts to resume from the latest checkpoint.
 
 
 
 
91
  """
 
 
92
  if self.tokenizer is None:
93
  self._initialize_tokenizer()
94
 
 
 
 
95
  # Resolve file paths
96
- if isinstance(files, str):
 
 
 
 
 
 
 
 
 
97
  path = Path(files)
98
  if path.is_dir():
99
  file_paths = [str(f) for f in path.glob("**/*.txt")]
@@ -108,43 +186,60 @@ class EthioBBPETrainer:
108
  raise ValueError("No valid training files found.")
109
 
110
  logger.info(f"Found {len(file_paths)} files for training.")
 
111
 
112
  # Checkpoint logic
113
  start_from_scratch = True
 
114
  if use_checkpoint:
115
  latest_ckpt = self._get_latest_checkpoint()
116
  if latest_ckpt:
117
  logger.info(f"Resuming from checkpoint: {latest_ckpt}")
118
- self.tokenizer = ByteLevelBPETokenizer.from_file(str(latest_ckpt))
119
  start_from_scratch = False
 
120
  else:
121
  logger.info("No checkpoint found. Starting from scratch.")
122
 
123
- # Initialize Trainer
124
- trainer = trainers.BpeTrainer(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  vocab_size=self.config.vocab_size,
126
  min_frequency=self.config.min_frequency,
127
  special_tokens=self.config.special_tokens,
128
- show_progress=self.config.show_progress,
129
- initial_alphabet=ByteLevelBPETokenizer.alphabet()
130
  )
131
 
132
- # Train
133
- logger.info("Starting training...")
134
- if start_from_scratch:
135
- self.tokenizer.train(files=file_paths, trainer=trainer)
136
- else:
137
- # Note: HuggingFace tokenizers library doesn't natively support
138
- # resuming BPE merge training mid-process easily without custom C++ extensions.
139
- # The checkpoint here primarily saves the state before finalization or
140
- # allows saving intermediate vocabularies if implemented in batches.
141
- # For this production version, we treat checkpoints as safety saves of the
142
- # current state before heavy operations or as versioned releases.
143
- self.tokenizer.train(files=file_paths, trainer=trainer)
144
-
145
  self.is_trained = True
146
  logger.info("Training completed successfully.")
147
 
 
 
 
 
 
 
 
 
 
 
 
148
  # Auto-save checkpoint after training
149
  self._save_checkpoint("final_pre_compress")
150
 
@@ -159,72 +254,278 @@ class EthioBBPETrainer:
159
  ckpts.sort(key=lambda p: p.stat().st_mtime, reverse=True)
160
  return ckpts[0]
161
 
162
- def _save_checkpoint(self, name: str = "latest"):
163
- """Save current tokenizer state to checkpoint."""
 
 
 
 
 
 
164
  if self.tokenizer is None:
165
  return
 
166
  ckpt_path = self.checkpoint_dir / f"checkpoint_{name}.json"
167
  self.tokenizer.save(str(ckpt_path))
168
- logger.info(f"Checkpoint saved to {ckpt_path}")
169
-
170
- def save(self, model_name: str = "ethio_bbpe", compress: bool = None):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  """
172
- Save the trained tokenizer.
173
 
174
  Args:
175
- model_name: Name of the model folder.
176
- compress: If True, saves vocab and merges in gzip format.
177
  Defaults to config.save_compressed.
 
 
 
 
 
 
 
 
178
  """
179
  if not self.is_trained and self.tokenizer is None:
180
  raise RuntimeError("Tokenizer not trained yet.")
181
 
 
182
  compress = compress if compress is not None else self.config.save_compressed
 
 
183
  model_path = self.output_dir / model_name
184
  model_path.mkdir(parents=True, exist_ok=True)
185
 
186
- logger.info(f"Saving model to {model_path} (compressed={compress})...")
187
 
188
- if compress:
189
- # Save standard tokenizer.json (required for HF loading)
 
 
 
 
190
  tokenizer_file = model_path / "tokenizer.json"
191
  self.tokenizer.save(str(tokenizer_file))
192
-
193
- # Extract and compress vocab and merges separately for space efficiency
194
- vocab = self.tokenizer.get_vocab()
195
- merges = self.tokenizer.get_merge_pairs()
196
-
197
- # Save compressed vocab
198
- vocab_path = model_path / "vocab.json.gz"
199
- with gzip.open(vocab_path, 'wt', encoding='utf-8') as f:
200
- json.dump(vocab, f)
201
-
202
- # Save compressed merges
203
- merges_path = model_path / "merges.txt.gz"
204
- with gzip.open(merges_path, 'wt', encoding='utf-8') as f:
205
- for pair in merges:
206
- f.write(f"{pair[0]} {pair[1]}\n")
207
-
208
- logger.info(f"Compressed artifacts saved: {vocab_path}, {merges_path}")
209
-
210
- # Calculate savings
211
- original_size = sum(f.stat().st_size for f in [tokenizer_file])
212
- compressed_size = sum(f.stat().st_size for f in [vocab_path, merges_path])
213
- logger.info(f"Storage saved: {(original_size - compressed_size) / 1024:.2f} KB")
214
- else:
215
- # Standard save
216
- self.tokenizer.save(str(model_path / "tokenizer.json"))
217
- self.tokenizer.model.save(str(model_path))
218
- logger.info("Standard model artifacts saved.")
219
 
220
  # Save config
221
  self.config.save(str(model_path / "config.json"))
222
 
 
 
 
223
  # Save metadata card for Hugging Face
224
  self._save_model_card(model_path)
225
 
 
 
 
226
  logger.info(f"Model successfully saved to {model_path}")
227
  return model_path
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
 
229
  def _save_model_card(self, path: Path):
230
  """Generate and save a README.md for Hugging Face Hub."""
@@ -243,12 +544,15 @@ datasets:
243
 
244
  # EthioBBPE Tokenizer
245
 
246
- This is a production-ready Byte-Level BPE tokenizer trained for robust text processing.
247
 
248
  ## Features
249
  - **Byte-Level**: Handles any Unicode character without <UNK>.
250
- - **Compressed Storage**: Supports gzip compression for efficient deployment.
251
- - **Checkpointing**: Built-in safety checkpoints during training.
 
 
 
252
 
253
  ## Usage
254
 
@@ -266,10 +570,35 @@ from tokenizers import Tokenizer
266
  tokenizer = Tokenizer.from_file("tokenizer.json")
267
  ```
268
 
 
 
 
 
 
 
 
 
 
 
269
  ## Training Configuration
270
  ```json
271
  {json.dumps(asdict(self.config), indent=2)}
272
  ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
  """
274
  with open(path / "README.md", 'w', encoding='utf-8') as f:
275
  f.write(card_content)
@@ -288,3 +617,118 @@ tokenizer = Tokenizer.from_file("tokenizer.json")
288
  if self.tokenizer is None:
289
  raise RuntimeError("Tokenizer not initialized")
290
  return self.tokenizer.decode(ids)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  #!/usr/bin/env python3
2
  """
3
  EthioBBPE: Production-Ready Byte-Level BPE Tokenizer Trainer
4
+ Advanced Features:
5
+ - Checkpointing with metadata and resume capability
6
+ - Multi-format compression (gzip, lzma, bz2)
7
+ - Model quantization for deployment
8
+ - Training metrics tracking
9
+ - Automatic backups
10
+ - Model validation utilities
11
+ - Multiple export formats
12
  """
13
 
14
  import os
15
  import json
16
  import gzip
17
+ import bz2
18
+ import lzma
19
  import shutil
20
+ import hashlib
21
  import logging
22
  from pathlib import Path
23
+ from typing import List, Optional, Union, Dict, Any, Tuple
24
  from dataclasses import dataclass, field, asdict
25
  from datetime import datetime
26
+ import tempfile
27
+ import struct
28
 
29
+ from tokenizers import ByteLevelBPETokenizer, trainers, Tokenizer
30
  from tokenizers.implementations import BaseTokenizer
31
 
32
  # Configure logging
 
47
  lowercase: bool = False
48
  dropout: Optional[float] = None
49
 
50
+ # File paths
51
+ data_dir: str = "./data"
52
+ model_save_dir: str = "./models"
53
+ model_name: str = "EthioBBPE"
54
+
55
  # Advanced features
56
+ use_checkpoint: bool = True
57
+ checkpoint_dir: str = "./models/checkpoints"
58
  save_compressed: bool = True
59
+ compression_format: str = "gzip" # Options: gzip, bz2, lzma
60
+ compression_level: int = 9 # 1-9, higher = better compression but slower
61
+ checkpoint_steps: Optional[int] = None
62
  num_threads: int = -1 # -1 for auto
63
+
64
+ # Backup and versioning
65
+ enable_backup: bool = True
66
+ max_checkpoints: int = 5 # Keep only last N checkpoints
67
+
68
+ # Quantization (for deployment optimization)
69
+ enable_quantization: bool = False
70
+ quantization_bits: int = 8 # 8-bit or 4-bit quantization
71
+
72
  def save(self, path: str):
73
  """Save configuration to JSON."""
74
  with open(path, 'w', encoding='utf-8') as f:
 
83
  return cls(**data)
84
 
85
 
86
+ @dataclass
87
+ class CheckpointMetadata:
88
+ """Metadata for checkpoint management."""
89
+ checkpoint_id: str
90
+ timestamp: str
91
+ vocab_size: int
92
+ training_step: int
93
+ is_final: bool
94
+ checksum: str
95
+ config: Dict[str, Any]
96
+ metrics: Optional[Dict[str, float]] = None
97
+
98
+ def to_dict(self) -> Dict[str, Any]:
99
+ return asdict(self)
100
+
101
+ @classmethod
102
+ def from_dict(cls, data: Dict[str, Any]) -> "CheckpointMetadata":
103
+ return cls(**data)
104
+
105
+
106
  class EthioBBPETrainer:
107
  """
108
+ Production-ready trainer for Byte-Level BPE with advanced features:
109
+ - Checkpointing with metadata tracking
110
+ - Multi-format compression (gzip, bz2, lzma)
111
+ - Model quantization for deployment
112
+ - Automatic backup management
113
+ - Training metrics collection
114
  """
115
 
116
+ def __init__(self, config: BBPEConfig = None):
117
+ self.config = config or BBPEConfig()
118
+ self.output_dir = Path(self.config.model_save_dir)
119
+ self.checkpoint_dir = Path(self.config.checkpoint_dir)
120
  self.tokenizer = None
121
  self.is_trained = False
122
+ self.training_metrics = {}
123
+ self.start_time = None
124
 
125
  # Create directories
126
  self.output_dir.mkdir(parents=True, exist_ok=True)
127
  self.checkpoint_dir.mkdir(parents=True, exist_ok=True)
128
 
129
  logger.info(f"Initialized EthioBBPETrainer with output dir: {self.output_dir}")
130
+ logger.info(f"Compression format: {self.config.compression_format}, Level: {self.config.compression_level}")
131
+ if self.config.enable_quantization:
132
+ logger.info(f"Quantization enabled: {self.config.quantization_bits}-bit")
133
 
134
  def _initialize_tokenizer(self):
135
  """Initialize the ByteLevelBPETokenizer."""
 
140
  )
141
  logger.info("Tokenizer initialized")
142
 
143
+ def train(self, files: Union[str, List[str]] = None, use_checkpoint: bool = None):
144
  """
145
  Train the tokenizer on a list of files or a directory.
146
 
147
  Args:
148
  files: Path to a file, list of files, or directory containing text files.
149
+ If None, uses files from config.data_dir
150
  use_checkpoint: If True, attempts to resume from the latest checkpoint.
151
+ Defaults to config.use_checkpoint
152
+
153
+ Returns:
154
+ Trained tokenizer object
155
  """
156
+ self.start_time = datetime.now()
157
+
158
  if self.tokenizer is None:
159
  self._initialize_tokenizer()
160
 
161
+ # Use config default if not specified
162
+ use_checkpoint = use_checkpoint if use_checkpoint is not None else self.config.use_checkpoint
163
+
164
  # Resolve file paths
165
+ if files is None:
166
+ # Use data_dir from config
167
+ data_path = Path(self.config.data_dir)
168
+ if data_path.is_dir():
169
+ file_paths = [str(f) for f in data_path.glob("**/*.txt")]
170
+ file_paths.extend([str(f) for f in data_path.glob("**/*.jsonl")])
171
+ file_paths.extend([str(f) for f in data_path.glob("**/*.json")])
172
+ else:
173
+ raise FileNotFoundError(f"Data directory not found: {self.config.data_dir}")
174
+ elif isinstance(files, str):
175
  path = Path(files)
176
  if path.is_dir():
177
  file_paths = [str(f) for f in path.glob("**/*.txt")]
 
186
  raise ValueError("No valid training files found.")
187
 
188
  logger.info(f"Found {len(file_paths)} files for training.")
189
+ logger.info(f"Training started at: {self.start_time}")
190
 
191
  # Checkpoint logic
192
  start_from_scratch = True
193
+ resumed_from = None
194
  if use_checkpoint:
195
  latest_ckpt = self._get_latest_checkpoint()
196
  if latest_ckpt:
197
  logger.info(f"Resuming from checkpoint: {latest_ckpt}")
198
+ self.tokenizer = Tokenizer.from_file(str(latest_ckpt))
199
  start_from_scratch = False
200
+ resumed_from = str(latest_ckpt)
201
  else:
202
  logger.info("No checkpoint found. Starting from scratch.")
203
 
204
+ # Calculate initial metrics
205
+ total_files = len(file_paths)
206
+ total_size = sum(Path(f).stat().st_size for f in file_paths)
207
+ self.training_metrics['initial'] = {
208
+ 'num_files': total_files,
209
+ 'total_bytes': total_size,
210
+ 'resumed_from': resumed_from
211
+ }
212
+
213
+ # Train
214
+ logger.info("Starting training...")
215
+ train_start = datetime.now()
216
+
217
+ # ByteLevelBPETokenizer.train() accepts parameters directly, not a trainer object
218
+ self.tokenizer.train(
219
+ files=file_paths,
220
  vocab_size=self.config.vocab_size,
221
  min_frequency=self.config.min_frequency,
222
  special_tokens=self.config.special_tokens,
223
+ show_progress=self.config.show_progress
 
224
  )
225
 
226
+ train_end = datetime.now()
227
+ train_duration = (train_end - train_start).total_seconds()
228
+
 
 
 
 
 
 
 
 
 
 
229
  self.is_trained = True
230
  logger.info("Training completed successfully.")
231
 
232
+ # Record final metrics
233
+ vocab = self.tokenizer.get_vocab()
234
+ self.training_metrics['final'] = {
235
+ 'vocab_size': len(vocab),
236
+ 'training_duration_sec': train_duration,
237
+ 'end_time': train_end.isoformat()
238
+ }
239
+
240
+ logger.info(f"Final vocabulary size: {len(vocab)}")
241
+ logger.info(f"Training duration: {train_duration:.2f} seconds")
242
+
243
  # Auto-save checkpoint after training
244
  self._save_checkpoint("final_pre_compress")
245
 
 
254
  ckpts.sort(key=lambda p: p.stat().st_mtime, reverse=True)
255
  return ckpts[0]
256
 
257
+ def _save_checkpoint(self, name: str = "latest", save_metadata: bool = True):
258
+ """
259
+ Save current tokenizer state to checkpoint with metadata.
260
+
261
+ Args:
262
+ name: Checkpoint name identifier
263
+ save_metadata: Whether to save metadata JSON alongside checkpoint
264
+ """
265
  if self.tokenizer is None:
266
  return
267
+
268
  ckpt_path = self.checkpoint_dir / f"checkpoint_{name}.json"
269
  self.tokenizer.save(str(ckpt_path))
270
+
271
+ # Calculate checksum for integrity verification
272
+ checksum = self._calculate_file_checksum(ckpt_path)
273
+
274
+ # Save metadata if requested
275
+ if save_metadata:
276
+ vocab = self.tokenizer.get_vocab()
277
+ metadata = CheckpointMetadata(
278
+ checkpoint_id=f"{name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}",
279
+ timestamp=datetime.now().isoformat(),
280
+ vocab_size=len(vocab),
281
+ training_step=-1, # Not used in HF tokenizers but kept for compatibility
282
+ is_final=(name == "final_pre_compress"),
283
+ checksum=checksum,
284
+ config=asdict(self.config),
285
+ metrics=self.training_metrics.copy()
286
+ )
287
+
288
+ metadata_path = self.checkpoint_dir / f"checkpoint_{name}_metadata.json"
289
+ with open(metadata_path, 'w', encoding='utf-8') as f:
290
+ json.dump(metadata.to_dict(), f, indent=2)
291
+ logger.info(f"Checkpoint metadata saved to {metadata_path}")
292
+
293
+ # Manage checkpoint rotation (keep only last N checkpoints)
294
+ if self.config.enable_backup and self.config.max_checkpoints > 0:
295
+ self._rotate_checkpoints()
296
+
297
+ logger.info(f"Checkpoint saved to {ckpt_path} (checksum: {checksum[:8]}...)")
298
+
299
+ def _calculate_file_checksum(self, filepath: Path) -> str:
300
+ """Calculate SHA256 checksum of a file."""
301
+ sha256_hash = hashlib.sha256()
302
+ with open(filepath, "rb") as f:
303
+ for byte_block in iter(lambda: f.read(4096), b""):
304
+ sha256_hash.update(byte_block)
305
+ return sha256_hash.hexdigest()
306
+
307
+ def _rotate_checkpoints(self):
308
+ """Keep only the most recent checkpoints based on max_checkpoints config."""
309
+ ckpts = list(self.checkpoint_dir.glob("checkpoint_*.json"))
310
+ # Exclude metadata files
311
+ ckpts = [c for c in ckpts if "_metadata" not in c.name]
312
+
313
+ if len(ckpts) > self.config.max_checkpoints:
314
+ # Sort by modification time, oldest first
315
+ ckpts.sort(key=lambda p: p.stat().st_mtime)
316
+ # Remove oldest checkpoints
317
+ num_to_remove = len(ckpts) - self.config.max_checkpoints
318
+ for i in range(num_to_remove):
319
+ old_ckpt = ckpts[i]
320
+ old_metadata = old_ckpt.parent / f"{old_ckpt.stem}_metadata.json"
321
+
322
+ try:
323
+ old_ckpt.unlink()
324
+ logger.info(f"Removed old checkpoint: {old_ckpt.name}")
325
+ if old_metadata.exists():
326
+ old_metadata.unlink()
327
+ logger.info(f"Removed old metadata: {old_metadata.name}")
328
+ except Exception as e:
329
+ logger.warning(f"Failed to remove old checkpoint {old_ckpt}: {e}")
330
+
331
+ def save(self, model_name: str = None, compress: bool = None,
332
+ compression_format: str = None, export_formats: List[str] = None):
333
  """
334
+ Save the trained tokenizer with advanced options.
335
 
336
  Args:
337
+ model_name: Name of the model folder. Defaults to config.model_name
338
+ compress: If True, saves vocab in compressed format.
339
  Defaults to config.save_compressed.
340
+ compression_format: Format for compression ('gzip', 'bz2', 'lzma').
341
+ Defaults to config.compression_format.
342
+ export_formats: List of export formats to generate.
343
+ Options: ['tokenizer.json', 'vocab_compressed', 'quantized', 'hf_export']
344
+ Defaults to all available formats.
345
+
346
+ Returns:
347
+ Path to saved model directory
348
  """
349
  if not self.is_trained and self.tokenizer is None:
350
  raise RuntimeError("Tokenizer not trained yet.")
351
 
352
+ model_name = model_name or self.config.model_name
353
  compress = compress if compress is not None else self.config.save_compressed
354
+ compression_format = compression_format or self.config.compression_format
355
+
356
  model_path = self.output_dir / model_name
357
  model_path.mkdir(parents=True, exist_ok=True)
358
 
359
+ logger.info(f"Saving model to {model_path} (compressed={compress}, format={compression_format})...")
360
 
361
+ # Determine which formats to export
362
+ if export_formats is None:
363
+ export_formats = ['tokenizer.json', 'vocab_compressed', 'hf_export']
364
+
365
+ # Always save standard tokenizer.json (required for HF loading)
366
+ if 'tokenizer.json' in export_formats:
367
  tokenizer_file = model_path / "tokenizer.json"
368
  self.tokenizer.save(str(tokenizer_file))
369
+ logger.info(f"Standard tokenizer.json saved: {tokenizer_file}")
370
+
371
+ # Save compressed vocab if requested
372
+ if compress and 'vocab_compressed' in export_formats:
373
+ self._save_compressed_vocab(model_path, compression_format)
374
+
375
+ # Save quantized version if enabled
376
+ if self.config.enable_quantization and 'quantized' in export_formats:
377
+ self._save_quantized_model(model_path)
378
+
379
+ # Generate Hugging Face export files
380
+ if 'hf_export' in export_formats:
381
+ self._save_hf_export_files(model_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
382
 
383
  # Save config
384
  self.config.save(str(model_path / "config.json"))
385
 
386
+ # Save training metrics
387
+ self._save_training_metrics(model_path)
388
+
389
  # Save metadata card for Hugging Face
390
  self._save_model_card(model_path)
391
 
392
+ # Log size comparison
393
+ self._log_size_comparison(model_path)
394
+
395
  logger.info(f"Model successfully saved to {model_path}")
396
  return model_path
397
+
398
+ def _get_compression_handler(self, format: str):
399
+ """Get the appropriate compression handler based on format."""
400
+ handlers = {
401
+ 'gzip': (gzip.open, '.gz', 'wt'),
402
+ 'bz2': (bz2.open, '.bz2', 'wt'),
403
+ 'lzma': (lzma.open, '.xz', 'wt')
404
+ }
405
+ if format not in handlers:
406
+ logger.warning(f"Unknown compression format '{format}', falling back to gzip")
407
+ format = 'gzip'
408
+ return handlers[format]
409
+
410
+ def _save_compressed_vocab(self, model_path: Path, format: str = 'gzip'):
411
+ """Save vocabulary in compressed format."""
412
+ vocab = self.tokenizer.get_vocab()
413
+ open_func, ext, mode = self._get_compression_handler(format)
414
+
415
+ vocab_path = model_path / f"vocab.json{ext}"
416
+
417
+ # Use compression level from config
418
+ if format == 'gzip':
419
+ with open_func(vocab_path, mode, encoding='utf-8', compresslevel=self.config.compression_level) as f:
420
+ json.dump(vocab, f)
421
+ elif format == 'bz2':
422
+ with open_func(vocab_path, mode, encoding='utf-8') as f:
423
+ json.dump(vocab, f)
424
+ elif format == 'lzma':
425
+ with open_func(vocab_path, mode, encoding='utf-8', preset=self.config.compression_level) as f:
426
+ json.dump(vocab, f)
427
+
428
+ logger.info(f"Compressed vocab saved ({format}): {vocab_path}")
429
+
430
+ # Calculate and log compression ratio
431
+ original_size = sum(len(k) + len(str(v)) for k, v in vocab.items())
432
+ compressed_size = vocab_path.stat().st_size
433
+ ratio = (1 - compressed_size / (original_size * 2)) * 100 if original_size > 0 else 0
434
+ logger.info(f"Compression ratio: {ratio:.1f}% (original ~{original_size*2} bytes -> {compressed_size} bytes)")
435
+
436
+ def _save_quantized_model(self, model_path: Path):
437
+ """Save a quantized version of the tokenizer for deployment."""
438
+ vocab = self.tokenizer.get_vocab()
439
+
440
+ # Create quantized vocabulary mapping
441
+ if self.config.quantization_bits == 8:
442
+ # 8-bit quantization: map tokens to uint8 range
443
+ dtype = 'uint8'
444
+ max_val = 255
445
+ elif self.config.quantization_bits == 4:
446
+ # 4-bit quantization: pack two tokens per byte
447
+ dtype = 'uint4'
448
+ max_val = 15
449
+ else:
450
+ logger.warning(f"Unsupported quantization bits: {self.config.quantization_bits}, using 8-bit")
451
+ dtype = 'uint8'
452
+ max_val = 255
453
+
454
+ # Sort vocab by ID for consistent ordering
455
+ sorted_vocab = sorted(vocab.items(), key=lambda x: x[1])
456
+
457
+ # Create quantized lookup table
458
+ quantized_data = {
459
+ 'vocab_size': len(sorted_vocab),
460
+ 'quantization_bits': self.config.quantization_bits,
461
+ 'dtype': dtype,
462
+ 'tokens': [token for token, _ in sorted_vocab],
463
+ 'ids': [idx for _, idx in sorted_vocab]
464
+ }
465
+
466
+ # Save quantized model
467
+ quantized_path = model_path / f"tokenizer_quantized_{self.config.quantization_bits}bit.json.gz"
468
+ with gzip.open(quantized_path, 'wt', encoding='utf-8', compresslevel=9) as f:
469
+ json.dump(quantized_data, f)
470
+
471
+ logger.info(f"Quantized model saved ({self.config.quantization_bits}-bit): {quantized_path}")
472
+
473
+ def _save_hf_export_files(self, model_path: Path):
474
+ """Save files needed for Hugging Face Hub export."""
475
+ # Save merges.txt if available (for BPE models)
476
+ try:
477
+ merges = self.tokenizer.model.get_merges()
478
+ if merges:
479
+ merges_path = model_path / "merges.txt"
480
+ with open(merges_path, 'w', encoding='utf-8') as f:
481
+ f.write("#version: 0.2\n")
482
+ for merge in merges:
483
+ f.write(f"{merge[0]} {merge[1]}\n")
484
+ logger.info(f"Merges file saved: {merges_path}")
485
+ except Exception as e:
486
+ logger.debug(f"No merges available or error saving: {e}")
487
+
488
+ # Save special tokens mapping
489
+ special_tokens_path = model_path / "special_tokens_map.json"
490
+ special_tokens = {
491
+ "unk_token": "<unk>",
492
+ "pad_token": "<pad>",
493
+ "bos_token": "<s>",
494
+ "eos_token": "</s>"
495
+ }
496
+ with open(special_tokens_path, 'w', encoding='utf-8') as f:
497
+ json.dump(special_tokens, f, indent=2)
498
+ logger.info(f"Special tokens map saved: {special_tokens_path}")
499
+
500
+ def _save_training_metrics(self, model_path: Path):
501
+ """Save training metrics to JSON."""
502
+ metrics_path = model_path / "training_metrics.json"
503
+
504
+ # Add additional metrics
505
+ full_metrics = {
506
+ **self.training_metrics,
507
+ 'config': asdict(self.config),
508
+ 'saved_at': datetime.now().isoformat()
509
+ }
510
+
511
+ with open(metrics_path, 'w', encoding='utf-8') as f:
512
+ json.dump(full_metrics, f, indent=2)
513
+
514
+ logger.info(f"Training metrics saved: {metrics_path}")
515
+
516
+ def _log_size_comparison(self, model_path: Path):
517
+ """Log size comparison between different saved formats."""
518
+ files_info = []
519
+ for f in model_path.iterdir():
520
+ if f.is_file():
521
+ size_kb = f.stat().st_size / 1024
522
+ files_info.append((f.name, size_kb))
523
+
524
+ files_info.sort(key=lambda x: x[1])
525
+
526
+ logger.info("Model artifacts size summary:")
527
+ for name, size in files_info:
528
+ logger.info(f" {name}: {size:.2f} KB")
529
 
530
  def _save_model_card(self, path: Path):
531
  """Generate and save a README.md for Hugging Face Hub."""
 
544
 
545
  # EthioBBPE Tokenizer
546
 
547
+ This is a production-ready Byte-Level BPE tokenizer with advanced features for deployment.
548
 
549
  ## Features
550
  - **Byte-Level**: Handles any Unicode character without <UNK>.
551
+ - **Multi-format Compression**: Supports gzip, bz2, and lzma compression.
552
+ - **Checkpointing**: Built-in safety checkpoints with metadata tracking.
553
+ - **Quantization**: Optional 8-bit/4-bit quantization for efficient deployment.
554
+ - **Training Metrics**: Comprehensive metrics tracking and logging.
555
+ - **Automatic Backup**: Checkpoint rotation to manage disk space.
556
 
557
  ## Usage
558
 
 
570
  tokenizer = Tokenizer.from_file("tokenizer.json")
571
  ```
572
 
573
+ ### Loading Compressed Vocab
574
+ ```python
575
+ import gzip
576
+ import json
577
+
578
+ # Load compressed vocabulary
579
+ with gzip.open("vocab.json.gz", 'rt', encoding='utf-8') as f:
580
+ vocab = json.load(f)
581
+ ```
582
+
583
  ## Training Configuration
584
  ```json
585
  {json.dumps(asdict(self.config), indent=2)}
586
  ```
587
+
588
+ ## Model Files
589
+ - `tokenizer.json`: Standard tokenizer file (required)
590
+ - `vocab.json.gz`: Compressed vocabulary (optional, smaller size)
591
+ - `config.json`: Training configuration
592
+ - `training_metrics.json`: Training statistics
593
+ - `special_tokens_map.json`: Special tokens mapping
594
+ - `README.md`: This file
595
+
596
+ ## Checkpoints
597
+ Checkpoints are saved in the `{self.checkpoint_dir}` directory with metadata including:
598
+ - Checkpoint ID and timestamp
599
+ - Vocabulary size
600
+ - SHA256 checksum for integrity verification
601
+ - Training metrics at checkpoint time
602
  """
603
  with open(path / "README.md", 'w', encoding='utf-8') as f:
604
  f.write(card_content)
 
617
  if self.tokenizer is None:
618
  raise RuntimeError("Tokenizer not initialized")
619
  return self.tokenizer.decode(ids)
620
+
621
+ def get_vocab_size(self) -> int:
622
+ """Get the current vocabulary size."""
623
+ if self.tokenizer is None:
624
+ return 0
625
+ return len(self.tokenizer.get_vocab())
626
+
627
+ def get_training_metrics(self) -> Dict[str, Any]:
628
+ """Get training metrics collected during training."""
629
+ return self.training_metrics.copy()
630
+
631
+ def validate_checkpoint(self, checkpoint_path: Union[str, Path]) -> bool:
632
+ """
633
+ Validate checkpoint integrity using checksum.
634
+
635
+ Args:
636
+ checkpoint_path: Path to checkpoint JSON file
637
+
638
+ Returns:
639
+ True if valid, False otherwise
640
+ """
641
+ checkpoint_path = Path(checkpoint_path)
642
+ metadata_path = checkpoint_path.parent / f"{checkpoint_path.stem}_metadata.json"
643
+
644
+ if not metadata_path.exists():
645
+ logger.warning(f"No metadata found for checkpoint: {checkpoint_path}")
646
+ return True # Can't validate without metadata
647
+
648
+ try:
649
+ with open(metadata_path, 'r', encoding='utf-8') as f:
650
+ metadata = json.load(f)
651
+
652
+ expected_checksum = metadata.get('checksum')
653
+ if not expected_checksum:
654
+ logger.warning("No checksum in metadata")
655
+ return True
656
+
657
+ actual_checksum = self._calculate_file_checksum(checkpoint_path)
658
+ is_valid = expected_checksum == actual_checksum
659
+
660
+ if is_valid:
661
+ logger.info(f"✓ Checkpoint validated: {checkpoint_path.name}")
662
+ else:
663
+ logger.error(f"✗ Checkpoint validation FAILED: {checkpoint_path.name}")
664
+ logger.error(f" Expected: {expected_checksum}")
665
+ logger.error(f" Actual: {actual_checksum}")
666
+
667
+ return is_valid
668
+ except Exception as e:
669
+ logger.error(f"Error validating checkpoint: {e}")
670
+ return False
671
+
672
+ def list_checkpoints(self) -> List[Dict[str, Any]]:
673
+ """
674
+ List all available checkpoints with their metadata.
675
+
676
+ Returns:
677
+ List of checkpoint info dictionaries
678
+ """
679
+ checkpoints = []
680
+ ckpt_files = sorted(self.checkpoint_dir.glob("checkpoint_*.json"))
681
+
682
+ for ckpt_file in ckpt_files:
683
+ if "_metadata" in ckpt_file.name:
684
+ continue
685
+
686
+ info = {
687
+ 'path': str(ckpt_file),
688
+ 'name': ckpt_file.name,
689
+ 'size_kb': ckpt_file.stat().st_size / 1024,
690
+ 'modified': datetime.fromtimestamp(ckpt_file.stat().st_mtime).isoformat()
691
+ }
692
+
693
+ # Try to load metadata
694
+ metadata_file = ckpt_file.parent / f"{ckpt_file.stem}_metadata.json"
695
+ if metadata_file.exists():
696
+ try:
697
+ with open(metadata_file, 'r', encoding='utf-8') as f:
698
+ metadata = json.load(f)
699
+ info['metadata'] = metadata
700
+ info['vocab_size'] = metadata.get('vocab_size', 'N/A')
701
+ info['is_final'] = metadata.get('is_final', False)
702
+ except Exception as e:
703
+ info['error'] = str(e)
704
+
705
+ checkpoints.append(info)
706
+
707
+ return checkpoints
708
+
709
+ @staticmethod
710
+ def load_compressed_vocab(vocab_path: Union[str, Path]) -> Dict[str, int]:
711
+ """
712
+ Load vocabulary from a compressed file.
713
+
714
+ Args:
715
+ vocab_path: Path to compressed vocab file (.gz, .bz2, .xz)
716
+
717
+ Returns:
718
+ Vocabulary dictionary
719
+ """
720
+ vocab_path = Path(vocab_path)
721
+
722
+ if vocab_path.suffix == '.gz':
723
+ with gzip.open(vocab_path, 'rt', encoding='utf-8') as f:
724
+ return json.load(f)
725
+ elif vocab_path.suffix == '.bz2':
726
+ with bz2.open(vocab_path, 'rt', encoding='utf-8') as f:
727
+ return json.load(f)
728
+ elif vocab_path.suffix in ['.xz', '.lzma']:
729
+ with lzma.open(vocab_path, 'rt', encoding='utf-8') as f:
730
+ return json.load(f)
731
+ else:
732
+ # Assume uncompressed JSON
733
+ with open(vocab_path, 'r', encoding='utf-8') as f:
734
+ return json.load(f)