fix(dataprep): don't emit a degenerate chunk for empty text (#7183)

* fix(dataprep): don't emit a degenerate chunk for empty text

smart_chunk_text feeds empty / whitespace-only text (which tokenizes to
zero tokens) into the single-chunk branch, which unconditionally returns
one chunk. That yields a lone-EOS "document" (input_ids=[eos]) or, when
the tokenizer has no eos_token_id, a zero-length input_ids=[] — an
invalid sample that breaks a downstream collator/trainer.

load_from_file already guards against this with a ValueError, but
chunk_text, smart_chunk_text and load_from_files do not, so batch-loading
a directory that contains an empty file silently injects garbage rows.

Return no chunks when the tokenized text is empty, so empty inputs
contribute nothing instead of a degenerate sample. load_from_file keeps
its explicit ValueError (its guard runs first).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Guard empty/whitespace text before tokenizing in raw_text

Real BPE/SentencePiece tokenizers emit tokens for spaces and newlines, so the len(tokens)==0 check let whitespace-only documents through as a degenerate lone-EOS sample. Guard on text.strip() before tokenizing (mirroring load_from_file), and raise in load_from_files when every file is empty so return_tokenized mode never falls back to a text-column dataset. Test now uses a whitespace-preserving tokenizer and covers both return_tokenized modes.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
This commit is contained in:
Andrew Chen 2026-07-23 15:56:51 +08:00 committed by GitHub
commit ed26d87574
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 102 additions and 0 deletions

View file

@ -312,8 +312,100 @@ def test_load_from_file_skips_non_object_json_lines():
return True
def test_smart_chunk_text_empty_input_returns_no_chunks():
"""Empty/whitespace text must yield no chunks. This tokenizer keeps one token
per char (like BPE/SentencePiece keeping spaces), so a len(tokens)==0 check
would miss whitespace; the fix guards on text.strip() before tokenizing."""
class WhitespacePreservingTokenizer:
def __init__(self, eos_token_id):
self.eos_token = "</s>" if eos_token_id is not None else None
self.eos_token_id = eos_token_id
def __call__(
self,
text,
return_tensors = None,
add_special_tokens = False,
):
token_ids = [ord(c) % 100 for c in text] # whitespace -> real tokens
if return_tensors == "pt":
return {"input_ids": [token_ids]}
return {"input_ids": token_ids}
def decode(
self,
token_ids,
skip_special_tokens = False,
):
return "".join(chr(32 + (t % 90)) for t in token_ids)
for eos_token_id in (2, None):
loader = RawTextDataLoader(
WhitespacePreservingTokenizer(eos_token_id), chunk_size = 2048, stride = 512
)
# Whitespace tokenizes to >0 tokens, so [] proves the pre-tokenize guard.
assert len(loader.tokenizer(" \n\t ")["input_ids"]) > 0
for text in ("", " \n\t "):
for return_tokenized in (True, False):
assert (
loader.smart_chunk_text(
text, chunk_size = 2048, stride = 512, return_tokenized = return_tokenized
)
== []
), f"no chunks for empty input (eos={eos_token_id}, text={text!r}, tokenized={return_tokenized})"
assert loader.chunk_text(text, return_tokenized = return_tokenized) == [], (
f"chunk_text: no chunks for empty input "
f"(eos={eos_token_id}, text={text!r}, tokenized={return_tokenized})"
)
print("test_smart_chunk_text_empty_input_returns_no_chunks passed")
return True
def test_load_from_files_all_empty_raises():
"""All-empty file list must raise (like load_from_file) instead of returning
a 0-row text-column dataset in return_tokenized mode."""
class WhitespacePreservingTokenizer:
eos_token = "</s>"
eos_token_id = 2
def __call__(
self,
text,
return_tensors = None,
add_special_tokens = False,
):
token_ids = [ord(c) % 100 for c in text]
if return_tensors == "pt":
return {"input_ids": [token_ids]}
return {"input_ids": token_ids}
loader = RawTextDataLoader(WhitespacePreservingTokenizer(), chunk_size = 2048, stride = 512)
paths = []
try:
for content in ("", " \n\t "):
with tempfile.NamedTemporaryFile("w", suffix = ".txt", delete = False) as f:
f.write(content)
paths.append(f.name)
raised = False
try:
loader.load_from_files(paths, return_tokenized = True)
except ValueError as e:
raised = True
assert "empty" in str(e).lower() or "whitespace" in str(e).lower(), str(e)
assert raised, "load_from_files must raise when all files are empty/whitespace"
finally:
for p in paths:
os.unlink(p)
print("test_load_from_files_all_empty_raises passed")
return True
if __name__ == "__main__":
success = test_raw_text_loader()
success = test_smart_chunk_text_single_chunk_no_eos_returns_plain_list() and success
success = test_load_from_file_skips_non_object_json_lines() and success
success = test_smart_chunk_text_empty_input_returns_no_chunks() and success
success = test_load_from_files_all_empty_raises() and success
sys.exit(0 if success else 1)

View file

@ -87,6 +87,10 @@ class RawTextDataLoader:
text_content, self.chunk_size, self.stride, return_tokenized
)
all_chunks.extend(chunks)
if not all_chunks:
# All files empty/whitespace: raise like load_from_file instead of
# create_causal_dataset([]) returning a 0-row text-column dataset.
raise ValueError("All files are empty or contain only whitespace")
return self.create_causal_dataset(all_chunks)
def chunk_text(
@ -139,6 +143,12 @@ class RawTextDataLoader:
f"stride ({stride}) must be smaller than chunk_size ({chunk_size}) to progress the chunking loop"
)
# Skip empty/whitespace text before tokenizing: BPE/SentencePiece emit
# real tokens for spaces/newlines, so a len(tokens)==0 check misses it
# and would yield a degenerate lone-EOS sample. Mirrors load_from_file.
if not text or not text.strip():
return []
# Tokenize the whole text once for accurate token counts
tokenized = self.tokenizer(text, return_tensors = "pt", add_special_tokens = False)
tokens = tokenized["input_ids"]