Files
unslothai--unsloth/unsloth/dataprep/raw_text.py
T
wehub-resource-sync e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:59:56 +08:00

351 lines
13 KiB
Python

# Copyright 2023-present Daniel Han-Chen & the Unsloth team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import os
import re
import json
import csv
from typing import List, Dict, Any, Union, Optional
from datasets import Dataset
from pathlib import Path
__all__ = [
"RawTextDataLoader",
"TextPreprocessor",
]
SUPPORTED_FORMATS = {
".txt": "plain_text",
".md": "markdown",
".json": "json_lines",
".jsonl": "json_lines",
".csv": "csv_text_column",
}
class RawTextDataLoader:
def __init__(
self,
tokenizer,
chunk_size = 2048,
stride = 512,
return_tokenized = True,
):
if chunk_size <= 0:
raise ValueError(f"chunk_size must be positive, got {chunk_size}")
if stride >= chunk_size:
raise ValueError(f"stride ({stride}) must be smaller than chunk_size ({chunk_size})")
self.tokenizer = tokenizer
self.chunk_size = chunk_size
self.stride = stride
self.return_tokenized = return_tokenized
def detect_format(self, file_path):
"""Auto-detect file format and parse accordingly"""
extension = Path(file_path).suffix.lower()
return SUPPORTED_FORMATS.get(extension, "plain_text")
def load_from_file(
self,
file_path,
return_tokenized = None,
):
"""Load raw text and convert to dataset"""
if return_tokenized is None:
return_tokenized = self.return_tokenized
file_format = self.detect_format(file_path)
text_content = self._read_file_by_format(file_path, file_format)
if not text_content or not text_content.strip():
raise ValueError(f"File '{file_path}' is empty or contains only whitespace")
chunks = self.smart_chunk_text(text_content, self.chunk_size, self.stride, return_tokenized)
return self.create_causal_dataset(chunks)
def load_from_files(
self,
file_paths,
return_tokenized = None,
):
"""Load multiple text files"""
if return_tokenized is None:
return_tokenized = self.return_tokenized
all_chunks = []
for file_path in file_paths:
file_format = self.detect_format(file_path)
text_content = self._read_file_by_format(file_path, file_format)
chunks = self.smart_chunk_text(
text_content, self.chunk_size, self.stride, return_tokenized
)
all_chunks.extend(chunks)
return self.create_causal_dataset(all_chunks)
def chunk_text(
self,
text,
return_tokenized = None,
):
"""Split text into overlapping chunks"""
if return_tokenized is None:
return_tokenized = self.return_tokenized
return self.smart_chunk_text(text, self.chunk_size, self.stride, return_tokenized)
def create_causal_dataset(self, chunks):
"""Create dataset for causal language modeling"""
if chunks and isinstance(chunks[0], dict):
# Already-tokenized chunks: reshape for Dataset.from_dict
input_ids = [chunk["input_ids"] for chunk in chunks]
attention_mask = [chunk["attention_mask"] for chunk in chunks]
# Labels == input_ids for causal LM
labels = [list(ids) for ids in input_ids]
return Dataset.from_dict(
{
"input_ids": input_ids,
"attention_mask": attention_mask,
"labels": labels,
}
)
else:
# Text strings (backward compatibility)
return Dataset.from_dict({"text": chunks})
def smart_chunk_text(
self,
text,
chunk_size,
stride,
return_tokenized = True,
):
"""
Intelligent chunking that:
1. Respects sentence/paragraph boundaries
2. Handles various text formats (.txt, .md, .json, etc.)
3. Maintains context with stride overlap
4. Returns tokenized chunks directly (more efficient) or text chunks
"""
# Tokenize the whole text once for accurate token counts
tokenized = self.tokenizer(text, return_tensors = "pt", add_special_tokens = False)
tokens = tokenized["input_ids"]
# Normalise tokenizer return formats
if hasattr(tokens, "__len__") and len(tokens) > 0:
if hasattr(tokens[0], "__len__"):
tokens = tokens[0]
elif isinstance(tokens, int):
# Tokenizer returned a count; build a range
tokens = list(range(tokens))
if len(tokens) <= chunk_size:
# Fits in a single chunk
if return_tokenized:
eos_token_id = getattr(self.tokenizer, "eos_token_id", None)
if eos_token_id is not None:
tokens = tokens.tolist() if hasattr(tokens, "tolist") else list(tokens)
tokens.append(eos_token_id)
attention_mask = [1] * len(tokens)
return [{"input_ids": tokens, "attention_mask": attention_mask}]
else:
eos_token = self.tokenizer.eos_token if self.tokenizer.eos_token else ""
return [text + eos_token]
chunks = []
start_idx = 0
while start_idx < len(tokens):
end_idx = min(start_idx + chunk_size, len(tokens))
chunk_tokens = tokens[start_idx:end_idx]
if return_tokenized:
chunk_tokens_list = (
chunk_tokens.tolist() if hasattr(chunk_tokens, "tolist") else list(chunk_tokens)
)
# Append EOS on the last or a full chunk
if end_idx == len(tokens) or len(chunk_tokens_list) == chunk_size:
eos_token_id = getattr(self.tokenizer, "eos_token_id", None)
if eos_token_id is not None:
chunk_tokens_list.append(eos_token_id)
attention_mask = [1] * len(chunk_tokens_list)
chunks.append({"input_ids": chunk_tokens_list, "attention_mask": attention_mask})
else:
# Decode back to text (backward compatibility)
chunk_text = self.tokenizer.decode(chunk_tokens, skip_special_tokens = True)
# Append EOS on the last or a full chunk
if end_idx == len(tokens) or len(chunk_tokens) == chunk_size:
eos_token = self.tokenizer.eos_token if self.tokenizer.eos_token else ""
chunk_text += eos_token
chunks.append(chunk_text)
# Advance with stride overlap
if end_idx == len(tokens):
break
start_idx += chunk_size - stride
return chunks
def _read_file_by_format(self, file_path, file_format):
"""Read file content based on detected format."""
with open(file_path, "r", encoding = "utf-8") as f:
if file_format == "plain_text" or file_format == "markdown":
return f.read()
elif file_format == "json_lines":
lines = []
for line in f:
try:
data = json.loads(line.strip())
text = self._extract_text_from_json(data)
if text:
lines.append(text)
except json.JSONDecodeError:
continue
return "\n\n".join(lines)
elif file_format == "csv_text_column":
reader = csv.DictReader(f)
texts = []
for row in reader:
text = self._extract_text_from_csv_row(row)
if text:
texts.append(text)
return "\n\n".join(texts)
return ""
# Cache text fields/columns for better performance
_TEXT_FIELDS = ("text", "content", "message", "body", "description", "prompt")
_TEXT_COLUMNS = _TEXT_FIELDS
def _extract_text_from_json(self, data):
"""Extract text from JSON object using common field names."""
for field in self._TEXT_FIELDS:
if field in data and isinstance(data[field], str):
return data[field]
return ""
def _extract_text_from_csv_row(self, row):
"""Extract text from CSV row using common column names."""
for column in self._TEXT_COLUMNS:
if column in row and row[column]:
return row[column]
return ""
class TextPreprocessor:
# Compile regex patterns once for better performance
_WHITESPACE_PATTERN = re.compile(r"[^\S\n]+")
_INVALID_CHARS_PATTERN = re.compile(r"[^\x20-\x7E\n]")
_MULTIPLE_SPACES_PATTERN = re.compile(r"[ ]{2,}")
_NEWLINE_SPACES_PATTERN = re.compile(r" *\n *")
_MULTIPLE_NEWLINES_PATTERN = re.compile(r"\n{3,}")
_CHAPTER_PATTERN = re.compile(r"^# (.+)$", re.MULTILINE)
_SECTION_PATTERN = re.compile(r"^## (.+)$", re.MULTILINE)
_SUBSECTION_PATTERN = re.compile(r"^### (.+)$", re.MULTILINE)
_CODE_BLOCK_PATTERN = re.compile(r"```(\w*)\n(.*?)\n```", re.DOTALL)
def clean_text(self, text):
"""Remove unwanted characters, normalize whitespace"""
text = text.replace("\r\n", "\n").replace("\r", "\n")
text = self._WHITESPACE_PATTERN.sub(" ", text)
text = self._INVALID_CHARS_PATTERN.sub("", text)
text = self._MULTIPLE_SPACES_PATTERN.sub(" ", text)
text = self._NEWLINE_SPACES_PATTERN.sub("\n", text)
text = self._MULTIPLE_NEWLINES_PATTERN.sub("\n\n", text)
return text.strip()
def extract_sections(self, text, patterns):
"""Extract specific sections (e.g., code blocks, quotes)"""
sections = []
for pattern in patterns:
# Compile pattern on first use and cache? Well, patterns are user-provided,
# so just use re.findall with compiled flags
matches = re.findall(pattern, text, re.MULTILINE | re.DOTALL)
sections.extend(matches)
return sections
def add_structure_tokens(self, text):
"""Add special tokens for structure (chapters, sections)"""
text = self._CHAPTER_PATTERN.sub(r"<|chapter|>\1<|/chapter|>", text)
text = self._SECTION_PATTERN.sub(r"<|section|>\1<|/section|>", text)
text = self._SUBSECTION_PATTERN.sub(r"<|subsection|>\1<|/subsection|>", text)
text = self._CODE_BLOCK_PATTERN.sub(r"<|code|\1|>\2<|/code|>", text)
return text
def validate_dataset(self, dataset):
"""
Check for:
- Minimum/maximum sequence lengths
- Character encoding issues
- Repeated content
- Empty chunks
"""
stats = {
"total_samples": len(dataset),
"empty_samples": 0,
"min_length": float("inf"),
"max_length": 0,
"avg_length": 0,
"repeated_content": 0,
"encoding_issues": 0,
"warnings": [],
}
texts = dataset["text"]
text_lengths = []
seen_texts = set()
for i, text in enumerate(texts):
if not text or len(text.strip()) == 0:
stats["empty_samples"] += 1
continue
# Check for encoding issues
try:
text.encode("utf-8")
except UnicodeEncodeError:
stats["encoding_issues"] += 1
# Calculate lengths
length = len(text)
text_lengths.append(length)
stats["min_length"] = min(stats["min_length"], length)
stats["max_length"] = max(stats["max_length"], length)
# Check for repeated content
text_hash = hash(text.strip())
if text_hash in seen_texts:
stats["repeated_content"] += 1
else:
seen_texts.add(text_hash)
# Calculate average length
if text_lengths:
stats["avg_length"] = sum(text_lengths) / len(text_lengths)
stats["min_length"] = stats["min_length"] if stats["min_length"] != float("inf") else 0
# Generate warnings
if stats["empty_samples"] > 0:
stats["warnings"].append(f"Found {stats['empty_samples']} empty samples")
if stats["repeated_content"] > 0:
stats["warnings"].append(f"Found {stats['repeated_content']} repeated samples")
if stats["encoding_issues"] > 0:
stats["warnings"].append(f"Found {stats['encoding_issues']} encoding issues")
if stats["min_length"] < 10:
stats["warnings"].append("Some samples are very short (< 10 characters)")
return stats