Stylometry¶
ruts.corpus.delta(), ruts.corpus.delta_profiles(), ruts.corpus.frequency_table(), ruts.corpus.z_scores(), ruts.corpus.zeta(), ruts.corpus.kilgarriff_chi2(), ruts.corpus.mendenhall_curve(), ruts.corpus.mendenhall_distance(), ruts.corpus.function_words_profile()
Description¶
Measures of stylometry and authorship attribution: distances between texts by the frequencies of the most frequent words (Burrows's Delta and its variants, as in stylo), markers of preferred and avoided words (Zeta), Kilgarriff's chi-square distance between corpora, the Mendenhall curve and the function word profile as author features. The functions work on lists of text units: lowercased word forms (the usual choice for Delta), lemmas or character N-grams (CharNgramsExtractor) - case and lemmatization are up to the extractor.
Burrows's Delta¶
A corpus is a dictionary "text name → units". frequency_table builds a table of relative frequencies: rows are texts, columns are the n_mfw most frequent units in descending order of mean relative frequency (ties broken alphabetically); culling keeps units occurring in at least the given share of texts, as in stylo. z_scores standardizes the columns with the sample standard deviation, like scale() in R; a column with identical frequencies in all texts gives zeros. delta computes a symmetric distance matrix from the z-scores (a DataFrame with text names) suitable for clustering and PCA; at least three texts are needed - with two, the z-scores degenerate to ±1 and all distances are equal.
Variants (DELTA_VARIANTS), formulas after the stylo sources, \(n\) - number of units, \(z_A\), \(z_B\) - z-score vectors of the texts:
| Variant | Key | Formula | Source |
|---|---|---|---|
| Burrows's Delta | burrows |
\(\frac{1}{n} \sum_i \lvert z_{A,i} - z_{B,i} \rvert\) | Burrows (2002), dist.delta |
| Quadratic Delta | quadratic |
\(\frac{1}{n} \sqrt{\sum_i (z_{A,i} - z_{B,i})^2}\) | Argamon (2008), dist.argamon |
| Eder's Delta | eder |
\(\sum_i \frac{n - i + 2}{n} \lvert z_{A,i} - z_{B,i} \rvert\), \(i\) - frequency rank of the unit | Eder, dist.eder |
| Cosine Delta | cosine |
\(1 - \frac{z_A \cdot z_B}{\lVert z_A \rVert \lVert z_B \rVert}\) | Smith and Aldridge (2011), Evert et al. (2015), dist.wurzburg |
Cosine Delta gives the best clustering quality by author in the experiments of Evert et al.; Burrows's Delta is the classic choice. The number of units is usually 100 to 500 most frequent words; for character N-grams - 100-200.
Parameters of delta:
| Parameter | Type | Default | Description |
|---|---|---|---|
corpus |
dict[str, list[str]] | - |
Units of the texts by text name |
n_mfw |
int | 100 |
Number of the most frequent units; None - all |
variant |
str | burrows |
Delta variant from DELTA_VARIANTS |
culling |
float | 0.0 |
Minimum share of texts a unit must occur in |
frequency_table(corpus, n_mfw=100, culling=0.0) and z_scores(table) take the same parameters and the table.
For authorship attribution there is delta_profiles(reference, samples, n_mfw, variant, culling, statistics): the most frequent units, culling and the statistics for the z-scores are taken from the reference texts reference (author profiles) or from a separate set statistics - for example, from the training windows when the profiles are concatenated from them and the profiles themselves are too few to estimate the spread of frequencies; the texts under test samples are described in the same units and normalized with the same statistics; the result is the distances from the tested texts to the reference ones, the nearest reference in a row is the presumed author. Unlike delta over a joint vocabulary, the tested texts affect neither the unit list nor the normalization, and the result for a text does not depend on which other texts are passed along with it.
Example
from ruts import WordsExtractor
from ruts.corpus import delta, delta_profiles, frequency_table
texts = {
"А": "Кот сидел на окне и смотрел на птиц. Птицы улетели, и кот уснул на окне.",
"Б": "Собака лежала на полу и дремала. Потом собака ела и снова дремала на полу.",
"В": "Завтра кот снова будет сидеть на окне и смотреть на птиц, а собака будет дремать.",
}
we = WordsExtractor(lowercase=True)
corpus = {name: we.extract(text) for name, text in texts.items()}
frequency_table(corpus, n_mfw=5).round(3)
# на и собака кот окне
# А 0.200 0.133 0.000 0.133 0.133
# Б 0.143 0.143 0.143 0.000 0.000
# В 0.133 0.067 0.067 0.067 0.067
delta(corpus, n_mfw=5).round(3)
# А Б В
# А 0.000 1.563 1.277
# Б 1.563 0.000 1.033
# В 1.277 1.033 0.000
delta(corpus, n_mfw=5, variant="cosine").round(3)
# А Б В
# А 0.000 1.782 1.452
# Б 1.782 0.000 1.202
# В 1.452 1.202 0.000
sample = {"?": we.extract("Кот проснулся на окне и снова смотрел на птиц.")}
delta_profiles(corpus, sample, n_mfw=5).round(3)
# А Б В
# ? 0.364 1.66 1.16
Zeta¶
Markers of preferred and avoided words after Burrows (2007) and Craig and Kinney (2009). Every text of both corpora is divided into segments of about segment_size words (the number of segments is the rounded ratio of the length to the size, at least one); for a word the share of segments of each corpus in which it occurs (\(DP\)) is computed. Zeta is the difference of shares \(DP_{target} - DP_{comparison}\) from −1 to 1 (zeta.craig in stylo notation; the classic Craig's Zeta \(DP_{target} + (1 - DP_{comparison})\) is larger by one), logarithmic Zeta is \(\log_2 \frac{DP_{target}}{DP_{comparison}}\) (Schöch et al. 2018), a zero share is replaced with half a segment. The list starts with the words preferred by the target corpus and ends with the avoided ones.
| Parameter | Type | Default | Description |
|---|---|---|---|
target |
list[str]/list[list[str]] | - |
Words of the target corpus - one text or a list of texts |
comparison |
list[str]/list[list[str]] | - |
Words of the comparison corpus |
segment_size |
int | 2000 |
Segment size in words |
top_n |
int | None |
Number of words from the start of the list; None - all |
The result is a list of ZetaScore(word, dp_target, dp_comparison, zeta, log_zeta) named tuples in descending Zeta order, ties broken by descending logarithmic Zeta and alphabetically.
Example
from ruts.corpus import zeta
zeta(corpus["А"], corpus["Б"], segment_size=5, top_n=2)
# [ZetaScore(word='кот', dp_target=0.6666666666666666, dp_comparison=0.0, zeta=0.6666666666666666, log_zeta=2.0),
# ZetaScore(word='окне', dp_target=0.6666666666666666, dp_comparison=0.0, zeta=0.6666666666666666, log_zeta=2.0)]
zeta(corpus["А"], corpus["Б"], segment_size=5)[-1]
# ZetaScore(word='собака', dp_target=0.0, dp_comparison=0.6666666666666666, zeta=-0.6666666666666666, log_zeta=-2.0)
Kilgarriff's chi-square¶
The distance between two corpora after Kilgarriff (2001): for the n_mfw most frequent words of the joint corpus the expected frequencies in the corpora are proportional to their sizes, \(\chi^2 = \sum (O - E)^2 / E\) over the words and both corpora. The larger the value, the more the corpora differ; the value grows with corpus size, so pairs of corpora are comparable with each other at equal sizes, as in Kilgarriff's experiments.
| Parameter | Type | Default | Description |
|---|---|---|---|
words_a |
list[str] | - |
Words of the first corpus |
words_b |
list[str] | - |
Words of the second corpus |
n_mfw |
int | 500 |
Number of the most frequent words of the joint corpus |
Example
from ruts.corpus import kilgarriff_chi2
round(kilgarriff_chi2(corpus["А"], corpus["Б"], n_mfw=5), 3)
# 6.018
Mendenhall curve¶
mendenhall_curve(words) - the shares of words of each length in characters (Mendenhall 1887), an author profile comparable between texts regardless of their size; mendenhall_distance(words_a, words_b) - the Jensen-Shannon distance with base 2 between the curves, from 0 (identical distributions) to 1.
Example
from ruts.corpus import mendenhall_curve, mendenhall_distance
{length: round(share, 3) for length, share in mendenhall_curve(corpus["А"]).items()}
# {1: 0.133, 2: 0.2, 3: 0.133, 4: 0.2, 5: 0.2, 7: 0.133}
round(mendenhall_distance(corpus["А"], corpus["Б"]), 3)
# 0.353
Function word profile¶
The shares of adpositions, coordinating and subordinating conjunctions, particles, pronouns, determiners and interjections (FUNCTION_UD_POS: ADP, CCONJ, SCONJ, PART, PRON, DET, INTJ) among the words of the text - by the first pymorphy3 analysis for a list of words and by the annotation for a Doc with parts of speech. Function words do not depend on the topic, so their profile is a classic authorship feature (the Marusenko school).
Example
from ruts.corpus import function_words_profile
{pos: round(share, 3) for pos, share in function_words_profile(corpus["А"]).items()}
# {'ADP': 0.2, 'CCONJ': 0.133, 'SCONJ': 0.0, 'PART': 0.0, 'PRON': 0.0, 'DET': 0.0, 'INTJ': 0.0}