A package using the Higher Criticism statistic to find discriminating words in two text corpora.
Project description
HCT-Bootstrap: Discriminating Words in Text Corpora
A Python package for identifying stable, statistically discriminating words between two text corpora, using Higher Criticism thresholding with bootstrap stabilization and an empirical within-corpus null.
Overview
This package provides tools to perform a robust comparison of two texts or two collections of texts. The goal is to move beyond simple word frequency counts and identify words that are "surprisingly" frequent in one corpus relative to the other — a hallmark of stylistic difference.
Two problems make this harder than it looks. First, a vocabulary contains thousands of candidate words, so some will appear discriminating by chance alone. Second, a single statistical run is unstable: small perturbations of the data can change which words are selected. This package addresses both. It repeats the selection across bootstrap resamples and keeps only the words that survive every one, and it calibrates how many resamples are needed by running the same procedure on a null comparison built from corpora that are known to share a generating mechanism.
The result is a word list produced without any analyst-chosen frequency cutoff or threshold, together with a false-discovery bound.
The package is particularly useful for analyzing and comparing text generated by Large Language Models, comparing authors, or tracking stylistic change in a corpus over time.
Higher Criticism for Word-Frequency Tables
The core statistical engine is the Higher Criticism (HC) test, adapted for textual analysis as described in Kipnis (2022).
Comparing two corpora involves thousands of individual hypothesis tests — one for each unique word. For each word we can compute a p-value representing the probability of observing its frequency difference by chance. But with thousands of tests, many small p-values will occur randomly: the multiple testing problem.
Traditional corrections like Bonferroni are too conservative here, often missing subtle but collectively significant signals. The Higher Criticism statistic is instead designed to detect a sparse, heterogeneous signal — which matches the assumption that an author's or model's style is defined not by a shift in every word, but by a small, unknown subset of words used idiosyncratically.
HC examines the ordered sequence of all p-values and finds the threshold where the number of surprisingly small p-values is most significant relative to what the null hypothesis would produce. This makes it well suited to identifying a faint but consistent stylistic fingerprint. The core statistical code is adapted from Alon Kipnis's TwoSampleHC repository.
References
-
Kipnis, A. (2022). Higher Criticism for Discriminating Word-Frequency Tables and Authorship Attribution. The Annals of Applied Statistics, 16(2), 1236–1252. https://arxiv.org/pdf/1911.01208
-
Donoho, D., & Jin, J. (2004). Higher Criticism for detecting sparse heterogeneous mixtures. The Annals of Statistics, 32(3), 962–994. https://arxiv.org/pdf/math/0410072
Installation
-
Activate your virtual environment. Python 3.8 or later.
-
Install from PyPI:
pip install hct-bootstrap
-
Download the spaCy model. The package uses spaCy for tokenization, and the English model is not installed automatically:
python -m spacy download en_core_web_sm
-
Optional display extras. For the in-notebook results table and the diagnostic plot:
pip install "hct-bootstrap[display]"
How the Method Works
The procedure has three moving parts, and two of them are the arguments you will most often set.
Bootstrap stabilization. At step n, the package has drawn n bootstrap resamples of the two corpora and run HC thresholding on each. A word counts as surviving only if it was selected in all n runs. The surviving count N̂(n) therefore only shrinks as n grows.
The null comparison. In parallel, the same procedure runs on a comparison where the two sides are known to come from the same source, so every surviving word is by construction a false discovery. N̂₀(n) is that count, taken as the maximum across the available null comparisons (conservative). Set it with null_method:
null_method |
Null construction |
|---|---|
'split' (default) |
Each input corpus is shuffled and split into two non-overlapping halves, which are compared to each other. |
'resample' |
Two independent with-replacement resamples are drawn from each input corpus and compared. |
'user' |
You supply a same-source corpus for each input via null_corpus1_docs / null_corpus2_docs; each original corpus is compared against its counterpart. |
The stopping rule. How many bootstrap iterations to run is decided by the data, not by you. Set it with q:
q |
Rule |
|---|---|
None (default) |
Strict: stop at the first n where no null word survives (N̂₀(n) = 0). |
| a float in (0, 1] | FDR: stop at the first n where FDP⁺(n) = (1 + N̂₀(n)) / max(N̂(n), 1) falls to q. The selected set then has false-discovery rate at most q. |
The final selection is the set of words surviving all n iterations, intersected with the words selected by a single HC run on the full corpora.
Quick Start
This example builds two synthetic corpora that differ only in a small set of injected "AI style" words, then recovers them.
from hct_bootstrap import analyze_and_display
import random
# Set random seed for reproducibility
random.seed(42)
background_vocab = [
"analysis", "system", "data", "model", "research", "method", "result", "time", "user", "network",
"process", "study", "group", "problem", "level", "information", "effect", "change", "design", "feature",
"performance", "approach", "case", "point", "development", "variable", "pattern", "value", "algorithm", "test",
"environment", "structure", "condition", "behavior", "factor", "application", "control", "theory", "practice", "solution",
"science", "technology", "paper", "review", "framework", "function", "state", "rule", "context", "form",
"concept", "equation", "experiment", "measure", "source", "type", "area", "base", "number", "size",
"quality", "property", "task", "technique", "principle", "resource", "material", "energy", "rate", "scale",
"phase", "stage", "component", "class", "architecture", "mechanism", "relation", "role", "impact", "access"
]
ai_style_words = ["delve", "intricate", "underscore", "meticulous", "crucial",
"tapestry", "robust", "realm", "nuance", "leverage"]
human_corpus = []
ai_corpus = []
# 1. Create the 'Human' corpus: 1000 documents of 250 random background words
for _ in range(1000):
doc = " ".join(random.choices(background_vocab, k=250))
human_corpus.append(doc)
# 2. Create the 'AI' corpus: same length and vocabulary, plus the injected style words
for _ in range(1000):
doc = " ".join(random.choices(background_vocab, k=250))
doc += " " + " ".join(ai_style_words)
ai_corpus.append(doc)
# 3. Run the analysis and display the report.
# coupled=False because the two lists are not paired one-to-one. If you had
# generated both corpora from a shared list of prompts, so that
# corpus1_docs[i] and corpus2_docs[i] come from the same prompt, you would
# set coupled=True (the lists must then be of equal length).
# q=None uses the strict stopping rule; null_method='split' builds the null
# by halving each input corpus.
results = analyze_and_display(
corpus1_docs=human_corpus,
corpus1_name="Human",
corpus2_docs=ai_corpus,
corpus2_name="AI",
coupled=False,
q=None,
null_method="split",
)
Example Report Output
=== BS-HCT Analysis Report ===
Comparing: 'Human' vs. 'AI'
Coupled test comparison: False
Null construction: split (always uncoupled)
Stopping rule: strict (stop when no null word survives)
Max t: 1000
--------------------
t= 1 | N_test= 17 | N0_max= 12 | FDP+=0.7647
t= 2 | N_test= 12 | N0_max= 6 | FDP+=0.5833
t= 3 | N_test= 11 | N0_max= 3 | FDP+=0.3636
t= 4 | N_test= 11 | N0_max= 2 | FDP+=0.2727
t= 5 | N_test= 11 | N0_max= 2 | FDP+=0.2727
t= 6 | N_test= 11 | N0_max= 2 | FDP+=0.2727
t= 7 | N_test= 11 | N0_max= 2 | FDP+=0.2727
t= 8 | N_test= 11 | N0_max= 2 | FDP+=0.2727
t= 9 | N_test= 11 | N0_max= 2 | FDP+=0.2727
t= 10 | N_test= 11 | N0_max= 1 | FDP+=0.1818
t= 11 | N_test= 11 | N0_max= 1 | FDP+=0.1818
t= 12 | N_test= 11 | N0_max= 1 | FDP+=0.1818
t= 13 | N_test= 11 | N0_max= 0 | FDP+=0.0909
--- Corpus Information (Post-Cleaning) ---
# words in Human: 249849
# words in AI: 259881
--- Full-Corpus HCT ---
HCT candidate words: 17
Full-corpus HC score: 4.6882
--- Stopping ---
Rule fired at iteration n = 13 (ran 13 iterations).
--- Stable Discriminating Words (11) ---
More frequent in 'Human':
principle
More frequent in 'AI':
crucial, delve, intricate, leverage, meticulous, nuance, realm, robust, tapestry, underscore
Selected words with HCT p-values:
| word | p_value | more_frequent_in |
|---|---|---|
| nuance | 4.003404e-271 | AI |
| crucial | 4.003404e-271 | AI |
| delve | 4.003404e-271 | AI |
| intricate | 4.003404e-271 | AI |
| leverage | 4.003404e-271 | AI |
| tapestry | 4.003404e-271 | AI |
| robust | 4.003404e-271 | AI |
| meticulous | 4.003404e-271 | AI |
| underscore | 4.003404e-271 | AI |
| realm | 4.003404e-271 | AI |
| principle | 1.631736e-07 | Human |
=== End of Report ===
A plot of N̂(n), N̂₀(n), and FDP⁺(n) against n is displayed after the report when matplotlib is installed.
Reading the report
The per-iteration trace shows the mechanism directly. A single HC run on the full corpora flags 17 candidate words, but stability winnows that to 11 by the third resample. Meanwhile the null count falls from 12 to 0, and the run stops at n = 13, the first iteration where no null word survives.
All ten injected style words are recovered. The eleventh selection, principle, is a chance artifact: both corpora draw from the same background vocabulary, so any difference in a background word is noise. Notice that its p-value is 264 orders of magnitude larger than the others — it sits exactly at the boundary of what the stability requirement can defend. This is what the reported FDP⁺ of 0.0909 means in practice: one false discovery among eleven selections, matching the bound the procedure derived for itself before anyone looked at the words.
To silence the per-iteration trace, pass verbose=False.
API Reference
The package exposes two functions. They take identical arguments; analyze_and_display prints the formatted report and plot shown above, while discriminate returns the results silently for programmatic use.
analyze_and_display(...) and discriminate(...)
Required
corpus1_docs,corpus2_docs(List[str]): the documents of each corpus.corpus1_name,corpus2_name(str): labels used throughout the report and results.coupled(bool):Trueifcorpus1_docs[i]is paired withcorpus2_docs[i]— for example, both generated from the same prompt. Requires lists of equal length, and resampling then draws the same document indices for both corpora, preserving the pairing.Falseresamples each corpus independently.
Selection behavior
q(float, optional, defaultNone): the stopping rule.Nonefor the strict rule, or a value in (0, 1] for the FDR rule. See How the Method Works above.null_method(str, default'split'):'split','resample', or'user'.null_corpus1_docs,null_corpus2_docs(List[str], optional): required whennull_method='user'. One same-source null corpus per input corpus.null_coupled(boolor(bool, bool), optional): whether each user-supplied null corpus is prompt-paired with its test corpus, in which case the pair is cleaned jointly to preserve alignment. Pass a single boolean to apply to both null comparisons, or a two-element tuple to set them separately. Only meaningful withnull_method='user'; defaults to matchingcoupled.max_t(int, default1000): safety cap on bootstrap iterations if the stopping rule never fires.random_seed(int, default42): seed for reproducible resampling.
Pre-processing
pos_tags(List[str], optional, defaultNone): restrict the analysis to these spaCy part-of-speech tags, for example['NOUN', 'VERB'].Nonekeeps all parts of speech. Named entities, punctuation, and tokens mixing letters with digits are always removed, and hyphenated compounds such as real-world are kept intact.nlp(spacy.Language, optional): a pre-loaded spaCy pipeline. IfNone,default_spacy_modelis loaded internally and cached.default_spacy_model(str, default'en_core_web_sm'): the model to load whennlpis not supplied.
Output
verbose(bool, defaultTrue): print the per-iteration trace of N̂(n), N̂₀(n), and FDP⁺(n).
Return value
Both functions return a dictionary:
| Key | Contents |
|---|---|
selected_words |
Sorted list of the stable discriminating words. |
selected_words_df |
Those words with p_value and more_frequent_in, ascending by p-value. |
n_rule, t_stop |
The iteration at which the rule fired, or None. Both keys hold the same value. |
fdp_at_stop |
FDP⁺(n) at the stopping iteration. |
rule |
'strict' or 'fdr'. |
q, null_method |
The settings used, echoed back. |
iterations_run |
Number of iterations actually run. |
empty_reason |
Explanation if the selection came back empty, otherwise None. |
fdp_curve |
List of (n, N̂(n), N̂₀(n), FDP⁺(n)) tuples — the data behind the plot. |
full_data_hc_score |
HC score from the single run on the full corpora. |
full_data_results_df |
Words selected by that single full-corpus run. |
test_survivors_df |
Surviving test words at the stopping iteration. |
null_survivors_dfs |
Surviving null words, one DataFrame per null comparison. |
cleaned_corpus1, cleaned_corpus2 |
The cleaned corpora as joined strings. |
Data
The corpora generated for the accompanying study — LLM-written scientific abstracts across sampling temperatures and model families, human versus LLM legislative summaries, and base versus instruction-tuned model summaries — are available in the corpora/ directory of this repository.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file hct_bootstrap-0.2.0.tar.gz.
File metadata
- Download URL: hct_bootstrap-0.2.0.tar.gz
- Upload date:
- Size: 29.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1fda13c70525cc240fa1de708ceeeecd776b669b74b1015b16cc1721ba4132d3
|
|
| MD5 |
22623c6a8718156383dab9fc9b2f0563
|
|
| BLAKE2b-256 |
5665dcb322ad4ffea1f2c483f993dcd8c879d1313c7a6acb73550d41c6c35a1e
|
File details
Details for the file hct_bootstrap-0.2.0-py3-none-any.whl.
File metadata
- Download URL: hct_bootstrap-0.2.0-py3-none-any.whl
- Upload date:
- Size: 25.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1137b7bd7bf8dbae6cc6d50713c30b0eac3f1d9ada44a7d89f3aa95a1c06b139
|
|
| MD5 |
eadd51882a5df7fef82332551134f0f8
|
|
| BLAKE2b-256 |
7c6f48431b3d0851b26706ef9d2d54f5bbf97ef0db4a708bbf1b5fcf10ac68f8
|