soup/soup_cli/commands/data.py

582 lines
19 KiB
Python

"""soup data — dataset inspection and tools."""
from __future__ import annotations
import json
import random
from pathlib import Path
import typer
from rich.console import Console
from rich.table import Table
from soup_cli.data.loader import load_raw_data
from soup_cli.data.validator import validate_and_stats
console = Console()
app = typer.Typer(no_args_is_help=True)
@app.command()
def inspect(
path: str = typer.Argument(..., help="Path to dataset file (jsonl, csv, parquet)"),
rows: int = typer.Option(5, "--rows", "-r", help="Number of sample rows to show"),
):
"""Inspect a dataset: show stats and sample rows."""
file_path = Path(path)
if not file_path.exists():
console.print(f"[red]File not found: {file_path}[/]")
raise typer.Exit(1)
console.print(f"[dim]Inspecting {file_path}...[/]\n")
data = load_raw_data(file_path)
result = validate_and_stats(data)
# Print stats
stats_table = Table(title="Dataset Stats")
stats_table.add_column("Metric", style="bold")
stats_table.add_column("Value")
stats_table.add_row("Total samples", str(result["total"]))
stats_table.add_row("Columns", ", ".join(result["columns"]))
stats_table.add_row("Avg length (chars)", str(result["avg_length"]))
stats_table.add_row("Min length", str(result["min_length"]))
stats_table.add_row("Max length", str(result["max_length"]))
stats_table.add_row("Empty fields", str(result["empty_fields"]))
stats_table.add_row("Duplicates", str(result["duplicates"]))
console.print(stats_table)
# Vision stats (if dataset contains images)
_show_vision_stats(data)
# Print sample rows
if rows > 0 and len(data) > 0:
console.print(f"\n[bold]Sample rows ({min(rows, len(data))}):[/]")
sample_table = Table(show_lines=True)
for col in result["columns"][:5]: # max 5 columns
sample_table.add_column(col, max_width=60)
for row in data[: min(rows, len(data))]:
values = [str(row.get(col, ""))[:60] for col in result["columns"][:5]]
sample_table.add_row(*values)
console.print(sample_table)
@app.command()
def validate(
path: str = typer.Argument(..., help="Path to dataset file"),
fmt: str = typer.Option(
"auto", "--format", "-f",
help="Expected format: auto, alpaca, sharegpt, chatml, dpo, kto, plaintext",
),
):
"""Validate dataset format and report issues."""
file_path = Path(path)
if not file_path.exists():
console.print(f"[red]File not found: {file_path}[/]")
raise typer.Exit(1)
data = load_raw_data(file_path)
# Auto-detect format if not specified
if fmt == "auto":
from soup_cli.data.formats import detect_format
try:
fmt = detect_format(data)
console.print(f"[dim]Auto-detected format: {fmt}[/]")
except ValueError as exc:
console.print(f"[red]{exc}[/]")
raise typer.Exit(1)
result = validate_and_stats(data, expected_format=fmt)
if result["issues"]:
console.print("[yellow]Issues found:[/]")
for issue in result["issues"]:
console.print(f" [yellow]![/] {issue}")
else:
console.print("[bold green]Dataset is valid![/]")
valid = result["valid_rows"]
total = result["total"]
console.print(f"\n[green]{valid}/{total} rows valid for {fmt} format[/]")
@app.command()
def convert(
path: str = typer.Argument(..., help="Input dataset file"),
to: str = typer.Option(
..., "--to", "-t",
help="Target format: alpaca, sharegpt, chatml",
),
output: str = typer.Option(
None, "--output", "-o",
help="Output file path (default: <input>_<format>.jsonl)",
),
):
"""Convert a dataset between formats (alpaca, sharegpt, chatml)."""
from soup_cli.data.formats import (
CONVERTIBLE_FORMATS,
detect_format,
format_to_messages,
messages_to_format,
)
file_path = Path(path)
if not file_path.exists():
console.print(f"[red]File not found: {file_path}[/]")
raise typer.Exit(1)
if to not in CONVERTIBLE_FORMATS:
console.print(
f"[red]Invalid target format: {to}[/]\n"
f"Supported: {', '.join(CONVERTIBLE_FORMATS)}"
)
raise typer.Exit(1)
data = load_raw_data(file_path)
if not data:
console.print("[red]Dataset is empty.[/]")
raise typer.Exit(1)
src_fmt = detect_format(data)
console.print(f"[dim]Detected source format: {src_fmt}[/]")
if src_fmt == to:
console.print(f"[yellow]Source and target format are both '{to}'. Nothing to convert.[/]")
raise typer.Exit()
if src_fmt == "dpo":
console.print("[red]Cannot convert DPO format (preference pairs are not conversations).[/]")
raise typer.Exit(1)
# Convert: source -> messages -> target
converted = []
failed = 0
for row in data:
messages = format_to_messages(row, src_fmt)
if messages is None:
failed += 1
continue
result = messages_to_format(messages, to)
if result is None:
failed += 1
continue
converted.append(result)
if not converted:
console.print("[red]All rows failed to convert.[/]")
raise typer.Exit(1)
# Determine output path
if output is None:
output = str(file_path.stem) + f"_{to}.jsonl"
out_path = Path(output)
_write_jsonl(out_path, converted)
console.print(
f"[green]Converted {len(converted)} rows:[/] {src_fmt} -> {to}\n"
f"Output: [bold]{out_path}[/]"
)
if failed > 0:
console.print(f"[yellow]{failed} rows failed to convert.[/]")
@app.command()
def merge(
files: list[str] = typer.Argument(..., help="Paths to dataset files to merge"),
output: str = typer.Option(
"merged.jsonl", "--output", "-o",
help="Output file path",
),
shuffle: bool = typer.Option(False, "--shuffle", help="Shuffle after merging"),
):
"""Merge multiple datasets into a single file."""
all_data: list[dict] = []
for file_str in files:
file_path = Path(file_str)
if not file_path.exists():
console.print(f"[red]File not found: {file_path}[/]")
raise typer.Exit(1)
data = load_raw_data(file_path)
console.print(f"[dim]Loaded {len(data)} rows from {file_path}[/]")
all_data.extend(data)
if not all_data:
console.print("[red]No data loaded from any file.[/]")
raise typer.Exit(1)
if shuffle:
random.shuffle(all_data)
out_path = Path(output)
_write_jsonl(out_path, all_data)
console.print(
f"[green]Merged {len(all_data)} rows from {len(files)} files.[/]\n"
f"Output: [bold]{out_path}[/]"
)
@app.command()
def dedup(
path: str = typer.Argument(..., help="Path to dataset file"),
output: str = typer.Option(
None, "--output", "-o",
help="Output file path (default: <input>_deduped.jsonl)",
),
threshold: float = typer.Option(
0.8, "--threshold",
help="MinHash similarity threshold (0.0-1.0)",
),
field: str = typer.Option(
None, "--field", "-f",
help="Field to hash (default: all text fields concatenated)",
),
):
"""Remove near-duplicate rows using MinHash (locality-sensitive hashing)."""
try:
from datasketch import MinHash, MinHashLSH
except ImportError:
console.print(
"[red]datasketch not installed.[/]\n"
"Install with: [bold]pip install 'soup-cli[data]'[/]"
)
raise typer.Exit(1)
file_path = Path(path)
if not file_path.exists():
console.print(f"[red]File not found: {file_path}[/]")
raise typer.Exit(1)
data = load_raw_data(file_path)
if not data:
console.print("[red]Dataset is empty.[/]")
raise typer.Exit(1)
console.print(f"[dim]Deduplicating {len(data)} rows (threshold={threshold})...[/]")
# Build MinHash for each row
num_perm = 128
lsh = MinHashLSH(threshold=threshold, num_perm=num_perm)
minhashes = []
for idx, row in enumerate(data):
if field:
text = str(row.get(field, ""))
else:
text = " ".join(str(v) for v in row.values() if v)
words = text.lower().split()
shingles = set()
for i in range(max(1, len(words) - 2)):
shingles.add(" ".join(words[i: i + 3]))
mhash = MinHash(num_perm=num_perm)
for shingle in shingles:
mhash.update(shingle.encode("utf-8"))
minhashes.append(mhash)
try:
lsh.insert(str(idx), mhash)
except ValueError:
pass # duplicate key, already inserted by LSH
# Collect unique indices
seen: set[int] = set()
unique_indices = []
for idx in range(len(data)):
if idx in seen:
continue
unique_indices.append(idx)
results = lsh.query(minhashes[idx])
for dup_idx_str in results:
seen.add(int(dup_idx_str))
unique_data = [data[idx] for idx in unique_indices]
removed = len(data) - len(unique_data)
# Write output
if output is None:
output = str(file_path.stem) + "_deduped.jsonl"
out_path = Path(output)
_write_jsonl(out_path, unique_data)
console.print(
f"[green]Dedup complete:[/] {len(data)} -> {len(unique_data)} rows "
f"([red]-{removed}[/] duplicates)\n"
f"Output: [bold]{out_path}[/]"
)
@app.command(name="filter")
def filter_data(
path: str = typer.Argument(..., help="Path to dataset file"),
output: str = typer.Option(
None, "--output", "-o",
help="Output file path (default: <input>_filtered.jsonl)",
),
perplexity: float = typer.Option(
None, "--perplexity", "--ppl",
help="Max perplexity threshold (rows above this are removed)",
),
coherence: float = typer.Option(
None, "--coherence",
help="Min coherence threshold 0.0-1.0 (rows below this are removed)",
),
perplexity_model: str = typer.Option(
"gpt2", "--ppl-model",
help="Model for perplexity scoring (default: gpt2)",
),
field: str = typer.Option(
None, "--field", "-f",
help="Field to score (default: all text fields concatenated)",
),
score_only: bool = typer.Option(
False, "--score-only",
help="Add scores to data without filtering (writes _scored.jsonl)",
),
):
"""Filter dataset by quality: perplexity and/or coherence scoring."""
file_path = Path(path)
if not file_path.exists():
console.print(f"[red]File not found: {file_path}[/]")
raise typer.Exit(1)
if perplexity is None and coherence is None and not score_only:
console.print(
"[red]Specify at least one filter: --perplexity, --coherence, or --score-only[/]"
)
raise typer.Exit(1)
data = load_raw_data(file_path)
if not data:
console.print("[red]Dataset is empty.[/]")
raise typer.Exit(1)
console.print(f"[dim]Scoring {len(data)} rows...[/]")
# Extract texts for scoring
texts = []
for row in data:
if field and field in row:
texts.append(str(row[field]))
else:
texts.append(" ".join(str(v) for v in row.values() if v))
# Compute coherence scores (lightweight, always computed)
from soup_cli.utils.quality import compute_coherence_score
coherence_scores = compute_coherence_score(texts)
# Compute perplexity scores (requires model, only if requested)
perplexity_scores = None
if perplexity is not None or score_only:
try:
from soup_cli.utils.quality import compute_perplexity_scores
console.print(f"[dim]Computing perplexity with {perplexity_model}...[/]")
perplexity_scores = compute_perplexity_scores(
texts, model_name=perplexity_model,
)
except ImportError:
console.print(
"[yellow]torch/transformers not available for perplexity scoring. "
"Skipping perplexity.[/]"
)
if score_only:
# Add scores to each row and write output
scored_data = []
for idx, row in enumerate(data):
scored_row = dict(row)
scored_row["_coherence_score"] = coherence_scores[idx]
if perplexity_scores is not None:
scored_row["_perplexity_score"] = round(perplexity_scores[idx], 2)
scored_data.append(scored_row)
if output is None:
output = str(file_path.stem) + "_scored.jsonl"
out_path = Path(output)
_write_jsonl(out_path, scored_data)
console.print(
f"[green]Scored {len(scored_data)} rows.[/]\n"
f"Output: [bold]{out_path}[/]"
)
return
# Filter
kept = []
removed = []
for idx, row in enumerate(data):
remove = False
if perplexity is not None and perplexity_scores is not None:
if perplexity_scores[idx] > perplexity:
remove = True
if coherence is not None and coherence_scores[idx] < coherence:
remove = True
if remove:
removed.append(row)
else:
kept.append(row)
if output is None:
output = str(file_path.stem) + "_filtered.jsonl"
out_path = Path(output)
_write_jsonl(out_path, kept)
console.print(
f"[green]Filter complete:[/] {len(data)} -> {len(kept)} rows "
f"([red]-{len(removed)}[/] removed)\n"
f"Output: [bold]{out_path}[/]"
)
if perplexity is not None and perplexity_scores is not None:
avg_ppl = sum(perplexity_scores) / len(perplexity_scores)
console.print(f"Avg perplexity: [bold]{avg_ppl:.1f}[/] (threshold: {perplexity})")
if coherence is not None:
avg_coh = sum(coherence_scores) / len(coherence_scores)
console.print(f"Avg coherence: [bold]{avg_coh:.3f}[/] (threshold: {coherence})")
@app.command()
def stats(
path: str = typer.Argument(..., help="Path to dataset file"),
):
"""Extended dataset statistics: length distribution, token counts, languages."""
from soup_cli.data.validator import extended_stats
file_path = Path(path)
if not file_path.exists():
console.print(f"[red]File not found: {file_path}[/]")
raise typer.Exit(1)
data = load_raw_data(file_path)
if not data:
console.print("[red]Dataset is empty.[/]")
raise typer.Exit(1)
ext_stats = extended_stats(data)
# Basic info table
info_table = Table(title=f"Extended Stats: {file_path.name}")
info_table.add_column("Metric", style="bold")
info_table.add_column("Value", justify="right")
info_table.add_row("Total samples", str(ext_stats["total"]))
info_table.add_row("", "")
info_table.add_row("[bold]Length (chars)[/]", "")
info_table.add_row(" p10", str(ext_stats["length_p10"]))
info_table.add_row(" p25", str(ext_stats["length_p25"]))
info_table.add_row(" p50 (median)", str(ext_stats["length_p50"]))
info_table.add_row(" p75", str(ext_stats["length_p75"]))
info_table.add_row(" p90", str(ext_stats["length_p90"]))
info_table.add_row("", "")
info_table.add_row("[bold]Tokens (approx)[/]", "")
info_table.add_row(" Average", str(ext_stats["avg_tokens"]))
info_table.add_row(" Min", str(ext_stats["min_tokens"]))
info_table.add_row(" Max", str(ext_stats["max_tokens"]))
if ext_stats["languages"]:
info_table.add_row("", "")
info_table.add_row("[bold]Languages (sample)[/]", "")
for lang, count in sorted(
ext_stats["languages"].items(), key=lambda x: -x[1]
):
info_table.add_row(f" {lang}", str(count))
console.print(info_table)
# Terminal histogram of lengths
try:
import io
import sys
import plotext as plt
lengths = ext_stats["lengths"]
if lengths:
# Force UTF-8 stdout on Windows to avoid UnicodeEncodeError
# plotext uses box-drawing chars (U+2500 etc.) that cp1251/cp1252 can't encode
original_stdout = sys.stdout
needs_redirect = (
sys.platform == "win32"
and hasattr(sys.stdout, "encoding")
and (sys.stdout.encoding or "").lower().replace("-", "") != "utf8"
)
if needs_redirect:
try:
sys.stdout = io.TextIOWrapper(
sys.stdout.buffer, encoding="utf-8", errors="replace",
)
except AttributeError:
pass # no .buffer (e.g. in tests), keep original
try:
plt.clear_figure()
plt.hist(lengths, bins=30)
plt.title("Text Length Distribution (chars)")
plt.xlabel("Length")
plt.ylabel("Count")
plt.theme("dark")
plt.show()
finally:
sys.stdout = original_stdout
except UnicodeEncodeError:
console.print(
"\n[dim]Histogram skipped (encoding issue).[/] "
"Set PYTHONIOENCODING=utf-8 to enable."
)
except ImportError:
console.print(
"\n[dim]Install plotext for histograms:[/] [bold]pip install plotext[/]"
)
def _show_vision_stats(data: list[dict]) -> None:
"""Show image statistics if dataset contains image fields."""
if not data:
return
# Check if this is a vision dataset
sample = data[0]
if "image" not in sample:
return
total = len(data)
has_image = sum(1 for row in data if row.get("image"))
missing_image = total - has_image
# Collect image file info
extensions: dict[str, int] = {}
existing = 0
for row in data:
img_path = row.get("image", "")
if not img_path:
continue
ext = Path(img_path).suffix.lower()
extensions[ext] = extensions.get(ext, 0) + 1
if Path(img_path).exists():
existing += 1
vision_table = Table(title="Vision Stats")
vision_table.add_column("Metric", style="bold")
vision_table.add_column("Value")
vision_table.add_row("Images referenced", str(has_image))
vision_table.add_row("Missing image field", str(missing_image))
vision_table.add_row("Images found on disk", str(existing))
if extensions:
ext_str = ", ".join(f"{ext} ({count})" for ext, count in sorted(extensions.items()))
vision_table.add_row("Image formats", ext_str)
console.print(vision_table)
def _write_jsonl(path: Path, data: list[dict]) -> None:
"""Write a list of dicts as JSONL."""
with open(path, "w", encoding="utf-8") as f:
for row in data:
f.write(json.dumps(row, ensure_ascii=False) + "\n")