Skip to content

Corpus comparison

ests.corpus.compare_corpora(), ests.corpus.compare_features(), ests.corpus.corpus_features(), ests.corpus.text_features(), ests.corpus.split_windows(), ests.corpus.sentence_rhythm()

Description

Comparison of two corpora by every feature of a text at once: which statistics tell authors, genres, translations, human and generated texts apart, and by how much. For single words keyness does the same, for the distances between texts - delta.

The texts of both corpora are split into windows of about the same size, so that the features do not depend on the length of a text. In split_windows the number of windows is the ratio of the number of words to the size of a window rounded half up, at least one, and the parts are equal: a long text gives windows within a few percent of the size, a text of one to two windows gives windows of 750 to 1499 words at a window of 1000, and a text shorter than min_words - half a window by default - gives none, so that short texts do not set windows of very different sizes against the others. A boundary goes before the opening marks of the first word of a window - dashes, quotes, brackets, the inverted ¿ and ¡ - so that no punctuation is lost, while a straight quote or a dash glued to the end of the previous word closes it and stays behind ("cuatro", —dijo Juan—). The features are computed for every window (text_features or a function of one's own), and for every feature the two sets of values are compared. The result is a DataFrame feature × statistics sorted by descending absolute Cliff's delta.

Features

text_features(text, nlp=None) returns 132 features prefixed by their source:

Prefix Features Source
basic_ shares of long, complex, simple, mono- and polysyllabic words, of letters, spaces and punctuation marks; letters and syllables per word BasicStats
readability_ every readability formula and the consensus grade ReadabilityStats
diversity_ every measure of lexical diversity DiversityStats
morph_ shares of the values of every morphological feature (morph_pos_NOUN, morph_mood_Sub, morph_polarity_Neg) and the markers of Spanish (morph_p_gerund, morph_p_mente_adverbs) MorphStats by the spaCy model
sents_ mean length of a sentence in words, standard deviation, coefficient of variation, autocorrelation of neighbouring lengths (sentence_rhythm) - the rhythm of the text SentsExtractor
punct_ frequencies of the punctuation marks by type per 1000 words and the share of the inverted marks punctuation_profile

Every feature is counted once. The reading time only follows the number of words in a window and is left out; the share of unique words repeats diversity_ttr, the words per sentence repeat sents_mean, and the markers of the moods repeat morph_mood_*, so they are left out as well.

The parts of speech and the features of a single value - polarity, polite, poss, reflex - are shares of all the words (morph_polarity_Neg is the share of the negations), the other features shares of the words that carry them (morph_mood_Sub is the subjunctive among the moods). A value that does not occur in a window gives 0, a feature absent from the window altogether (no verbs - no tense) gives nan. A value of several values, as the model writes PronType=Int,Rel of que and Case=Acc,Nom of usted, is shared equally among its parts, so the shares of a feature still sum to one.

The morphology is parsed by the model es_core_news_sm without its parser, which takes most of the time: a window of 1000 words takes about 0.07 s. The parse of dependencies would add a third to that for a single marker, morph_p_ser, which reads the copulas from it; a pipeline passed in nlp runs whole but for the entity recognizer, so functools.partial(text_features, nlp=get_nlp()) brings the parser and morph_p_ser back, and another model goes into the comparison the same way. A text longer than the max_length of the pipeline raises SourceError, so a novel is compared by windows and not whole.

corpus_features(texts, window, features) returns the matrix of the features of the windows indexed by (number of the text, number of the window) - for classifiers of one's own - and compare_features(table_a, table_b, labels, n_bootstrap, seed) compares two such tables: compare_corpora is corpus_features for each corpus followed by compare_features. The split serves when the features are computed once for several corpora and pairs of them are to be compared, all the authors pairwise for instance.

The shares of spaces, letters and punctuation marks (basic_p_spaces, basic_p_letters, basic_p_punctuations) count the characters as they are: indents, double and non-breaking spaces of the files reflect the typesetting of an edition and not the text. In a corpus from different sources collapse them beforehand, for instance with re.sub(r"[^\S\n]+", " ", text).

Statistics

For a feature with the values \(x_1 \dots x_{n_A}\) in corpus A and \(y_1 \dots y_{n_B}\) in corpus B (undefined and infinite values dropped; with fewer than two values on a side - nan):

Column Description
mean_A, mean_B, median_A, median_B means and medians
median_diff, ci_low, ci_high the difference of the medians and its 95% percentile bootstrap interval: both sets are resampled n_bootstrap times (bootstrap_median_diff)
cohen_d \(d = (\bar{x} - \bar{y}) / s\), \(s\) - the pooled standard deviation; 0.2 - a small effect, 0.5 - medium, 0.8 - large (calc_cohen_d)
cliff_delta \(\delta = P(x > y) - P(x < y)\) from −1 to 1; \(\lvert\delta\rvert\) < 0.147 - a negligible effect, < 0.33 - small, < 0.474 - medium, large otherwise (Romano et al. 2006; calc_cliff_delta)
auc the feature as a classifier on its own: the share of the pairs of windows where the value in A is greater than in B, ties counted as half; \(\delta = 2 \cdot AUC - 1\), 0.5 - the feature does not tell the corpora apart
u, p_value the Mann-Whitney U statistic and the two-sided p-value (scipy.stats.mannwhitneyu)
p_holm the p-value with Holm's correction for the number of features (holm_correction): a table of a hundred rows without a correction invites false discoveries
n_A, n_B number of windows with a defined value
n_texts_A, n_texts_B number of texts behind those windows

Cliff's delta and the AUC come from the same U statistic and agree with each other; Cohen's d is sensitive to outliers and to departures from normality, so it is best read next to the delta.

Windows of one text are not independent

The test and the effect sizes take every window for an independent observation, and the windows of one text are not: they share its plot, characters, narrator and edition. With few texts in a corpus the p-values are too small and reflect the texts chosen as much as the corpora - in the example below two novels of one author differ in 40 features by the same test. The bootstrap resamples whole texts instead, the level text of the index that corpus_features sets (a cluster bootstrap), so its interval accounts for the spread between the texts; it needs at least two texts on each side and is rough with only a few. A table of one's own without that level has every row taken for a text of its own.

Parameters

Parameter Type Default Description
a list[str] - Texts of the first corpus
b list[str] - Texts of the second corpus
window int 1000 Size of a window in words; None - the whole texts
features callable None Function of the features of a text; None - text_features
labels tuple[str, str] ("A", "B") Names of the corpora for the columns
n_bootstrap int 1000 Number of bootstrap samples
seed int 0 Seed of the random number generator; None - a random one
min_words int None Smallest number of words in a window; None - half a window, or one when window is None

Usage example

Galdós against Unamuno, three novels each from Project Gutenberg: Marianela, Misericordia and Torquemada en la hoguera against Niebla, Abel Sánchez and La tía Tula - 197 and 117 windows of 1000 words, under half a minute after the download.

Example

Code:

from urllib.request import urlopen

from ests.corpus import compare_corpora


def gutenberg(number):
    url = f"https://www.gutenberg.org/cache/epub/{number}/pg{number}.txt"
    text = urlopen(url).read().decode("utf-8")
    start = text.index("\n", text.index("*** START OF"))
    return text[start : text.index("*** END OF")]


galdos = [gutenberg(number) for number in (17340, 21831, 15206)]
unamuno = [gutenberg(number) for number in (49836, 44512, 44358)]

result = compare_corpora(galdos, unamuno, window=1000, labels=("Galdós", "Unamuno"))
columns = [
    "median_Galdós",
    "median_Unamuno",
    "ci_low",
    "ci_high",
    "cohen_d",
    "cliff_delta",
    "auc",
    "p_holm",
]
result[columns].head(5).round(3)

features = [
    "basic_letters_per_word",
    "readability_lix",
    "morph_p_gerund",
    "morph_polarity_Neg",
    "sents_mean",
    "punct_dash",
    "punct_exclamation",
    "diversity_yule_k",
]
result.loc[features, columns].round(3)

result.loc["sents_mean", ["n_Galdós", "n_Unamuno", "n_texts_Galdós", "n_texts_Unamuno"]].to_dict()

Result:

                    median_Galdós  median_Unamuno  ci_low  ci_high  cohen_d  cliff_delta    auc  p_holm
diversity_ttr               0.494           0.420   0.066    0.088    3.159        0.976  0.988     0.0
diversity_httr              0.898           0.874   0.021    0.028    3.118        0.975  0.988     0.0
diversity_brunet_w         10.775          11.528  -0.897   -0.660   -3.073       -0.975  0.012     0.0
diversity_dttr             29.349          23.840   4.804    6.488    3.048        0.975  0.987     0.0
diversity_mttr              0.034           0.042  -0.009   -0.007   -3.103       -0.975  0.013     0.0

                        median_Galdós  median_Unamuno  ci_low  ci_high  cohen_d  cliff_delta    auc  p_holm
basic_letters_per_word          4.499           4.152   0.272    0.433    1.734        0.784  0.892     0.0
readability_lix                40.006          30.074   7.212   15.156    1.285        0.743  0.872     0.0
morph_p_gerund                  0.069           0.037   0.030    0.036    1.458        0.727  0.864     0.0
morph_polarity_Neg              0.016           0.027  -0.013   -0.008   -1.320       -0.613  0.193     0.0
sents_mean                     17.386          11.364   3.195    8.500    1.016        0.672  0.836     0.0
punct_dash                      9.045          39.157 -38.658  -23.021   -1.810       -0.722  0.139     0.0
punct_exclamation               9.970          24.096 -25.033   -5.744   -1.323       -0.634  0.183     0.0
diversity_yule_k              104.572         105.919  -9.766    6.096   -0.257       -0.117  0.442     1.0

{'n_Galdós': 197, 'n_Unamuno': 117, 'n_texts_Galdós': 3, 'n_texts_Unamuno': 3}

Galdós has the richer vocabulary: in 99% of the pairs of windows his has the larger share of distinct words (AUC 0.988). The measures built on the number of distinct words - TTR and its transformations, MATTR, MTLD, the hapaxes - tell the authors apart with a delta above 0.9, while Simpson's index, Yule's K and Herdan's Vm, which weigh the frequent words, hardly do (below 0.12): the difference lies in the rare vocabulary and not in the repetition of the frequent words. His words and sentences are longer, and he uses nearly twice as many gerunds among the verb forms. Unamuno writes in dialogue and in negations: four times as many dashes per 1000 words, more than twice as many exclamation and question marks, and more negations among the words.

Of the 132 features, 91 have a corrected p-value below 0.01 and 62 show a large effect by Cliff's delta, but the test takes the 314 windows for independent, and they come from six novels: by the same test Marianela and Torquemada en la hoguera, two novels of Galdós, differ in 40 features. The interval of the difference of the medians resamples whole novels and is the safer guide - for the length of a sentence it spans 3.2 to 8.5 words, where the windows alone would give 4.9 to 7.5 - though three texts on a side are few for a bootstrap as well, and a comparison of authors wants as many texts as can be found.

Features of one's own, for instance the syntactic ones, are passed as a function:

Example

from ests import SyntaxStats
from ests.corpus import compare_corpora, text_features
from ests.utils import get_nlp

nlp = get_nlp()


def features(text):
    stats = SyntaxStats(nlp(text)).get_stats()
    return {**text_features(text), **{f"syntax_{key}": value for key, value in stats.items()}}


compare_corpora(galdos, unamuno, window=1000, features=features, labels=("Galdós", "Unamuno"))