Skip to Main Content
Lightning Talk Beginner MIT License First Talk

Quality Clustering

Proposal status is Rejected
Session Description

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.

References

Session Categories

Introducing a FOSS project or a new version of a popular project
Talk License: MIT License

Speakers

Maria Thurkadayil
Maria Thurkadayil Student | Chennai Mathematical Instituite

Master’s Student looking for opportunities to integrate research with relevent technologies.