Sampling Script
Content-agnostic, topic-erased, judge-free quality bucketing for IFT / pretraining text corpora — with first-class Indic-language support (script routing, code-mixing, romanization, matra/conjunct complexity).
Builds a representation whose variance is driven by how something is written (fluency, richness, structure, repetition) rather than what it's about, clusters that representation into buckets, validates that the buckets track quality rather than topic, and draws a statistically-sized sample per bucket for human review.
This is a package, not a script: every stage is a small, independently swappable module, driven by one YAML config. This README is the full reference — everything the package can currently do, in one place.
Install
This project uses uv for environment and dependency management — no manual venv, no pip install. uv.lock pins every dependency (including transitive ones) for a reproducible install; uv sync reads it and builds .venv from scratch in seconds.
uv sync # core pipeline (TF-IDF fallback) + pytest, ~330MB, no GPU needed
uv sync --extra embeddings # + sentence-transformers (real style embeddings; pulls in torch)
uv sync --extra gpu # + a CUDA-enabled torch build
uv sync --all-extras # everything
uv sync also installs the dev dependency group (pytest) by default — that's a PEP 735 group, not a pip extra, so it never ends up in a built wheel; skip it with uv sync --no-dev for a leaner env.
If you specifically need a CUDA build of torch that doesn't match the default PyPI wheel for your platform, point uv at the right index — see uv's PyTorch integration guide, e.g. uv sync --extra gpu --index pytorch=https://download.pytorch.org/whl/cu124.
No uv? curl -LsSf https://astral.sh/uv/install.sh | sh, or see the install docs.
Quickstart
uv run qaxis init-config --output config.yaml # a full default config to start editing
uv run qaxis run --input demo_synthia.jsonl --output-dir out
uv run transparently uses .venv (creating/updating it from uv.lock first if needed) — no source .venv/bin/activate required.
uv run qaxis run --config config.yaml --input /data/corpus/ --output-dir out \
--checkpoint-dir ./ckpt --devices cuda:0 cuda:1
What each stage does
qaxis run walks through seven stages, every one of them independently configurable and reported on in report.md:
# | Stage | Module | What it does |
|---|
0 | Load & route | qaxis.io, qaxis.text_utils
| Load JSONL (file or directory), flatten multi-turn conversations, detect script per document, compute code-mix index and a romanized-Indic heuristic |
1 | Feature extraction | qaxis.features
| Run every enabled extractor (see below) over the corpus in one pass, plus a data-driven function-word vector |
2 | Length control | qaxis.features.length_control
| Residualize length-confounded columns against log(token count) |
3 | Style signal | qaxis.embeddings
| Embed affix+punctuation character n-grams (or raw text) with sentence-transformers, falling back to TF-IDF+SVD |
4 | Topic erasure | qaxis.topic
| Fuse features + style signal, erase a topic label from the fused vector, validate with a before/after linear topic-probe |
5 | Judge-free quality axis | qaxis.discovery
| Discover a quality signal with no LLM/judge involved: corruption-contrast, PCA, or both |
6 | Cluster & validate | qaxis.clustering
| Sweep k, cluster, bootstrap Jaccard stability, and check the clustering agrees with the quality proxy far more than with topic |
7 | Sample & report | qaxis.sampling, qaxis.report
| LQAS-sized medoid+random sample per bucket, full markdown report with plots |
CLI reference
qaxis init-config [--output PATH]
qaxis embed [--config PATH] [--input PATH] [--checkpoint-dir DIR]
[--devices DEV [DEV ...]] [--model NAME]
qaxis run [--config PATH] [--input PATH] [--checkpoint-dir DIR]
[--devices DEV [DEV ...]] [--model NAME] [--output-dir DIR]
init-config — writes Config.default() to a YAML file (config.yaml unless --output is given). Start here, then edit the sections you care about.
embed — pre-computes style embeddings into a resumable checkpoint. Requires --checkpoint-dir (or embedding.checkpoint_dir in the config) and sentence-transformers to be installed (uv sync --extra embeddings); exits with a clear error otherwise rather than silently falling back.
run — the full pipeline. Requires --input and --output-dir (or the config equivalents).
Every flag other than --config is a convenience override for the handful of things people change per invocation — input.path, embedding.checkpoint_dir, output.dir, embedding.devices, embedding.model. Everything else (which features run, erasure method, cluster sweep, sample sizing, ...) only exists in the YAML config, so a run is fully reproducible from --config alone. Every run also writes run_config.yaml into the output directory with the exact config actually used.
No --config? Both embed and run fall back to Config.default() with your CLI overrides applied on top — you don't need a config file at all for a quick run with default settings.
Config reference
qaxis init-config writes every field below with its default. A config file only needs to contain the keys you want to change — see Partial overrides below.
input
field | default | meaning |
|---|
path
| ""
| A .jsonl file or a directory of them (set via --input) |
format
| "jsonl"
| Loader to use — see Input formats available |
embedding
field | default | meaning |
|---|
checkpoint_dir
| null
| Resumable embedding checkpoint directory; null = no checkpoint, embed in memory each run |
backend
| "auto"
| "auto" | "sentence_transformer" | "tfidf" — see Embedding backends available
|
model
| sentence-transformers/all-MiniLM-L6-v2
| Any sentence-transformers model name |
style_input
| "ngrams"
| "ngrams" embeds affix+punctuation character n-grams (topic-robust, recommended); "text" embeds the raw body — only sensible with a genuinely content-controlled style model
|
devices
| null
| e.g. ["cuda:0", "cuda:1"]; null = auto-detect every visible CUDA device, else CPU |
batch_size
| 128
| Encoder batch size |
shard_size
| 5000
| Docs per checkpoint shard — smaller = more frequent resume points |
features
field | default | meaning |
|---|
enabled
| all 6 (see below) | Which extractors run — names must be in FEATURE_REGISTRY |
include_function_words
| true
| Add the data-driven function-word vector (separate from enabled, fused at its own weight) |
top_functionword_k
| 150
| Vocabulary size for the function-word vector |
mattr_window
| 30
| Window size for Moving-Average TTR |
char_ngram_n
| 3
| N for affix+punctuation character n-grams (used by compression and the style signal) |
corpus_lm_order
| 3
| N-gram order for the in-corpus perplexity model |
length_control.enabled
| true
| Residualize length-confounded columns |
length_control.columns
| hapax_rate, yules_k, honores_r, log_perplexity, affix_punct_ngram_richness
| Which columns get residualized against log-length |
topic
field | default | meaning |
|---|
column
| null
| An existing column to erase against; null = derive one via TF-IDF + k-means |
kmeans_k
| 12
| k for the derived-topic fallback |
erasure_method
| "residualize"
| "residualize" | "none" — see Topic-erasure methods available
|
discovery
field | default | meaning |
|---|
methods
| ["corruption_contrast", "pca"]
| Which judge-free quality-axis methods run — see Discovery methods available |
corruption.sample_n
| 300
| Clean documents sampled to build the corruption-contrast training set |
corruption.rate_per_doc
| 3
| Corrupted copies generated per sampled document |
pca.n_components
| 5
| Components computed (only the first is used as the quality axis) |
clustering
field | default | meaning |
|---|
k_sweep
| [4, 6, 8, 10, 12]
| Candidate cluster counts; best k chosen by silhouette |
bootstrap_resamples
| 20
| Resamples for bootstrap Jaccard cluster stability |
sampling
field | default | meaning |
|---|
lqas_upper
| 0.80
| "Good bucket" benchmark for LQAS accept/reject sizing |
lqas_lower
| 0.50
| "Bad bucket" benchmark |
output
field | default | meaning |
|---|
dir
| "./out"
| Where report.md and the CSV artifacts go |
top-level
field | default | meaning |
|---|
random_seed
| 42
| Seed for every stochastic step (k-means, bootstrap, corruption sampling, review sampling) |
Partial overrides
A config file only needs the keys you're changing — everything else falls back to Config.default():
features:
enabled: [lexical_diversity, structural]
include_function_words: false
discovery:
methods: [pca]
Feature extractors available
Set via config.features.enabled. List them from a shell:
uv run python -c "from qaxis.features.registry import FEATURE_REGISTRY; print(list(FEATURE_REGISTRY))"
name | module | columns produced | notes |
|---|
lexical_diversity
| features/lexical.py
| n_tokens, n_sentences, mean_sent_len, sent_len_sd, mattr, hapax_rate, yules_k, honores_r, distinct_1, distinct_2, word_len_mean, word_len_sd
| MATTR is length-robust by construction; the rest are length-controlled in Stage 2 |
structural
| features/structural.py
| table_rows, bullets, numbered, headers, code_fence, repeated_lines, blank_line_ratio
| Markdown/formatting density, content-agnostic |
punctuation
| features/punctuation.py
| punct_ratio, ellipsis_ratio, excl_ratio, quest_ratio
| Cheap register/tone signal |
compression
| features/compression.py
| compression_ratio, affix_punct_ngram_richness
| gzip ratio (redundancy proxy) + Sapkota-style topic-robust character n-gram richness |
indic_complexity
| features/indic_complexity.py
| script_consistency, code_mix_index, romanization_signal, matra_conjunct_density
| Echoes Stage-0 script signals, adds virama/conjunct density for Brahmic scripts |
perplexity
| features/perplexity.py
| log_perplexity
| Stateful — fits a per-script in-corpus n-gram LM first, then scores each doc against it |
Not in this registry (controlled separately, config.features.include_function_words):
name | module | what it produces |
|---|
function words | features/function_words.py
| A wide, L2-normalized matrix over the top-K most frequent corpus tokens (a data-driven, language-agnostic stand-in for a curated stopword list — the FineWeb-2 / Burrows's-Delta approach). Fused into the representation at its own weight rather than as scalar columns. |
Discovery methods available
Set via config.discovery.methods. Both run by default and are cross-checked against each other in the report.
name | module | what it does |
|---|
corruption_contrast
| discovery/corruption.py
| Samples clean docs, generates corrupted copies (sentence shuffle, repetition injection, truncation, random deletion), trains a classifier to tell them apart, scores the real corpus with its decision function. Reuses whichever features.enabled extractors are configured — no separate feature definition to maintain. |
pca
| discovery/pca_axis.py
| PCA on the topic-erased vector; PC1 is the quality axis. Reports feature loadings so the axis is interpretable. |
Topic-erasure methods available
Set via config.topic.erasure_method.
name | module | what it does |
|---|
residualize
| topic/erasure.py
| One-shot linear regression of the fused vector on a topic one-hot, keeping the residual — a practical approximation of LEACE/INLP. Validated in the report with a before/after linear topic-probe. |
none
| topic/erasure.py
| Pass-through — useful for ablations (see tests/test_smoke.py::test_topic_erasure_ablation_changes_result, which asserts residualize retains meaningfully less topic-attributable variance than none). |
Embedding backends available
Set via config.embedding.backend.
name | module | requires | behavior |
|---|
auto (default)
| — | — | Tries sentence_transformer, falls back to tfidf if unavailable |
sentence_transformer
| embeddings/sentence_transformer_backend.py
| uv sync --extra embeddings
| Real embeddings; multi-GPU via sentence-transformers' multi-process pool when >1 device is given |
tfidf
| embeddings/tfidf_backend.py
| nothing extra | TF-IDF + TruncatedSVD, fit fresh each call, no GPU/network needed |
Input formats available
Set via config.input.format.
name | module | accepts |
|---|
jsonl (default)
| io/jsonl_loader.py
| A single .jsonl file, or a directory (globbed for every *.jsonl inside and concatenated). Handles both single-turn (instruction/response) and multi-turn (messages/turns/conversation/dialogue) records, flattening multi-turn conversations into one transcript per row. |
Multi-GPU embedding & resume
For large corpora, split embedding from analysis. Sync the embeddings extra first:
uv sync --extra embeddings
uv run qaxis embed --input /data/corpus/ --checkpoint-dir ./ckpt --devices cuda:0 cuda:1 cuda:2 cuda:3
uv run qaxis run --input /data/corpus/ --checkpoint-dir ./ckpt --output-dir out
embed writes shard files (shard_00000.npz, ...) keyed by doc_id. Re-running the exact same command — after a crash, a timeout, or to add more GPUs — skips every doc_id already embedded and only fills what's missing. run does the same automatically if pointed at an incomplete checkpoint. Omit --devices to auto-detect every visible CUDA device.
Outputs
<output-dir>/
report.md walkthrough of every stage with embedded plots
plots/*.png
review_sheet.csv medoid + LQAS-sized random sample per bucket
bucket_manifest.csv bucket sizes, stability, quality scores, sample sizes
assignments.csv every record's bucket + quality scores
run_config.yaml the exact config used, for reproducibility
report.md walks Stage 0 (script/code-mixing) through Stage 7 (sampling plan), including the topic-erasure before/after probe accuracy and the ARI/AMI topic-vs-quality validation — the two numbers that tell you whether the buckets are actually tracking quality rather than domain.
In review_sheet.csv, only role == "random" rows should feed an accept/reject decision — role == "medoid" rows are for orientation only and are deliberately excluded from the statistical sample.
Extending qaxis
New input format (parquet, a database export, ...): subclass qaxis.io.base.CorpusLoader, implement load(path) -> pd.DataFrame producing the required columns (see that module's docstring), add it to qaxis.io.registry.LOADER_REGISTRY, and set input.format in the config.
New feature extractor: subclass qaxis.features.base.FeatureExtractor, implement compute_row(ctx) -> dict (and fit(df, config) if it needs a corpus-level pass, like the perplexity extractor's per-script language model), add it to qaxis.features.registry.FEATURE_REGISTRY, and list its name in config.features.enabled. It's automatically available to the corruption-contrast discovery stage too, with no extra wiring.
New topic-erasure method (true LEACE, INLP): add a function to qaxis.topic.erasure.ERASURE_METHODS and set topic.erasure_method.
New embedding backend (a real content-controlled style model like CISR/LUAR): subclass qaxis.embeddings.base.EmbeddingBackend, register it in qaxis.embeddings.registry.BACKEND_REGISTRY, set embedding.backend.
New discovery method: add its name to qaxis.discovery.registry.DISCOVERY_METHODS and call it from qaxis.pipeline.run alongside the existing two.
Testing
uv run pytest tests/
pytest comes from the default dev dependency group, so a plain uv sync (or letting uv run sync implicitly) already has it — no extra install step. The smoke tests run the full pipeline on the bundled demo corpus using the TF-IDF/CPU fallback path, so they need no GPU and no optional dependencies:
test_run_on_single_file / test_run_on_directory — the pipeline completes and produces every expected artifact for both input shapes.
test_reduced_feature_and_discovery_config — a config enabling only a subset of features and discovery methods still runs, and the report reflects it.
test_topic_erasure_ablation_changes_result — erasure_method: none retains ~100% of representation variance against topic, residualize much less; if this regresses, erasure broke.
test_config_round_trip — Config survives a to_yaml / from_yaml cycle unchanged.
Updating dependencies
uv add <package> # add a new core dependency
uv add --optional embeddings <package> # add to an extras group instead
uv add --group dev <package> # add a dev-only dependency
uv lock --upgrade # re-resolve everything to latest compatible versions
uv sync # apply uv.lock to .venv
Caveats
Judge/LLM score columns, if present in the input, are read exactly once, purely as a post-hoc sanity check after clustering — never as an input to features, erasure, or discovery.
residualize topic erasure is a one-shot linear approximation of LEACE/INLP. The pipeline validates it with a before/after linear topic-probe; if accuracy doesn't fall close to chance, iterate erasure (or write a stronger method and register it) before trusting the buckets.
Indic/code-mixing signals (script routing, romanization heuristic, matra/conjunct density) are dependency-free reference implementations. Swap in GlotLID, IndicXlit, and a trained KenLM per language-script for production use.
Any cluster with bootstrap Jaccard stability < 0.6 is not a reliable bucket — the report flags these; merge, resplit, or route to individual review.
Human quality judgments have limited value in predicting downstream training utility (DataComp-LM, arXiv:2406.11794). Validate any pruning rule against a real downstream signal before relying on it.