Исправлен .gitignore: пакет stylometry/features добавлен в репозиторий
This commit is contained in:
13
lab1/.gitignore
vendored
13
lab1/.gitignore
vendored
@@ -2,10 +2,9 @@ __pycache__/
|
|||||||
*.py[cod]
|
*.py[cod]
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.idea/
|
.idea/
|
||||||
nltk_data/
|
/nltk_data/
|
||||||
data/processed/
|
/data/processed/
|
||||||
features/
|
/features/
|
||||||
results/*
|
/results/*
|
||||||
!results/README.md
|
!/results/README.md
|
||||||
|
/exports/
|
||||||
exports/
|
|
||||||
|
|||||||
17
lab1/stylometry/features/__init__.py
Normal file
17
lab1/stylometry/features/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
"""Признаковое пространство стилометрии."""
|
||||||
|
|
||||||
|
from .stylometry import (
|
||||||
|
FEATURE_GROUP_PREFIXES,
|
||||||
|
FEATURE_GROUPS,
|
||||||
|
StylometricVectorizer,
|
||||||
|
describe_feature,
|
||||||
|
feature_dictionary,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FEATURE_GROUP_PREFIXES",
|
||||||
|
"FEATURE_GROUPS",
|
||||||
|
"StylometricVectorizer",
|
||||||
|
"describe_feature",
|
||||||
|
"feature_dictionary",
|
||||||
|
]
|
||||||
99
lab1/stylometry/features/base.py
Normal file
99
lab1/stylometry/features/base.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"""Вспомогательные функции для вычисления признаков."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
from collections import Counter
|
||||||
|
from typing import Iterable, Sequence
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
def safe_div(numerator: float, denominator: float, default: float = 0.0) -> float:
|
||||||
|
"""Деление без исключения при нулевом знаменателе."""
|
||||||
|
if denominator == 0:
|
||||||
|
return default
|
||||||
|
return float(numerator) / float(denominator)
|
||||||
|
|
||||||
|
|
||||||
|
def finite(value: float, default: float = 0.0) -> float:
|
||||||
|
"""Заменяет NaN и бесконечности на допустимое число."""
|
||||||
|
try:
|
||||||
|
result = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
return result if math.isfinite(result) else default
|
||||||
|
|
||||||
|
|
||||||
|
def as_list(row: pd.Series, column: str) -> list:
|
||||||
|
value = row.get(column, [])
|
||||||
|
if isinstance(value, list):
|
||||||
|
return value
|
||||||
|
if isinstance(value, tuple):
|
||||||
|
return list(value)
|
||||||
|
if value is None or (isinstance(value, float) and math.isnan(value)):
|
||||||
|
return []
|
||||||
|
return list(value)
|
||||||
|
|
||||||
|
|
||||||
|
def word_tokens(tokens: Sequence[str]) -> list[str]:
|
||||||
|
"""Токены, содержащие хотя бы одну букву или цифру."""
|
||||||
|
return [token for token in tokens if re.search(r"[A-Za-z0-9]", token)]
|
||||||
|
|
||||||
|
|
||||||
|
def alphabetic_tokens(tokens: Sequence[str]) -> list[str]:
|
||||||
|
return [token for token in tokens if re.search(r"[A-Za-z]", token)]
|
||||||
|
|
||||||
|
|
||||||
|
def lower_tokens(tokens: Sequence[str]) -> list[str]:
|
||||||
|
return [token.lower() for token in tokens]
|
||||||
|
|
||||||
|
|
||||||
|
def sentence_word_counts(row: pd.Series) -> list[int]:
|
||||||
|
sentences = as_list(row, "sentences")
|
||||||
|
counts = [len(word_tokens(sentence)) for sentence in sentences]
|
||||||
|
counts = [count for count in counts if count > 0]
|
||||||
|
return counts or [len(word_tokens(as_list(row, "tokens")))]
|
||||||
|
|
||||||
|
|
||||||
|
def count_set(tokens: Iterable[str], vocabulary: set[str]) -> int:
|
||||||
|
return sum(1 for token in tokens if token.lower() in vocabulary)
|
||||||
|
|
||||||
|
|
||||||
|
def rate_set(tokens: Sequence[str], vocabulary: set[str]) -> float:
|
||||||
|
return safe_div(count_set(tokens, vocabulary), len(tokens))
|
||||||
|
|
||||||
|
|
||||||
|
def counter(tokens: Iterable[str]) -> Counter:
|
||||||
|
return Counter(token.lower() for token in tokens)
|
||||||
|
|
||||||
|
|
||||||
|
def mean(values: Sequence[float]) -> float:
|
||||||
|
return float(np.mean(values)) if values else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def std(values: Sequence[float]) -> float:
|
||||||
|
return float(np.std(values, ddof=0)) if values else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def median(values: Sequence[float]) -> float:
|
||||||
|
return float(np.median(values)) if values else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def percentile(values: Sequence[float], q: float) -> float:
|
||||||
|
return float(np.percentile(values, q)) if values else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def word_lengths(tokens: Sequence[str]) -> list[int]:
|
||||||
|
lengths: list[int] = []
|
||||||
|
for token in tokens:
|
||||||
|
letters = re.sub(r"[^A-Za-z]", "", token)
|
||||||
|
if letters:
|
||||||
|
lengths.append(len(letters))
|
||||||
|
return lengths
|
||||||
|
|
||||||
|
|
||||||
|
def strip_punctuation(token: str) -> str:
|
||||||
|
return re.sub(r"^[^A-Za-z0-9]+|[^A-Za-z0-9]+$", "", token)
|
||||||
38
lab1/stylometry/features/characters.py
Normal file
38
lab1/stylometry/features/characters.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""Символьные признаки: частоты букв, регистр, цифры, пунктуация."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import string
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from .base import safe_div
|
||||||
|
|
||||||
|
|
||||||
|
def extract(row: pd.Series) -> dict[str, float]:
|
||||||
|
text = str(row.get("text", ""))
|
||||||
|
lower = text.lower()
|
||||||
|
letters = [char for char in lower if "a" <= char <= "z"]
|
||||||
|
n_letters = max(len(letters), 1)
|
||||||
|
n_chars = max(len(text), 1)
|
||||||
|
|
||||||
|
features: dict[str, float] = {}
|
||||||
|
for char in string.ascii_lowercase:
|
||||||
|
features[f"char_freq_{char}"] = safe_div(letters.count(char), n_letters)
|
||||||
|
|
||||||
|
upper = sum(1 for char in text if char.isupper())
|
||||||
|
digits = sum(1 for char in text if char.isdigit())
|
||||||
|
spaces = sum(1 for char in text if char.isspace())
|
||||||
|
punctuation = sum(1 for char in text if not char.isalnum() and not char.isspace())
|
||||||
|
features.update(
|
||||||
|
{
|
||||||
|
"char_letter_count": float(len(letters)),
|
||||||
|
"char_upper_rate": safe_div(upper, n_letters),
|
||||||
|
"char_digit_rate": safe_div(digits, n_chars),
|
||||||
|
"char_space_rate": safe_div(spaces, n_chars),
|
||||||
|
"char_punctuation_rate": safe_div(punctuation, n_chars),
|
||||||
|
"char_apostrophe_rate": safe_div(text.count("'"), n_chars),
|
||||||
|
"char_quote_rate": safe_div(text.count('"'), n_chars),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return features
|
||||||
98
lab1/stylometry/features/dialogue.py
Normal file
98
lab1/stylometry/features/dialogue.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
"""Признаки, специфичные для игровых реплик и разговорной речи."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from ..lexicons import (
|
||||||
|
CONTRACTIONS,
|
||||||
|
DISCOURSE_MARKERS,
|
||||||
|
HEDGES,
|
||||||
|
INTENSIFIERS,
|
||||||
|
INTERJECTIONS,
|
||||||
|
NEGATIONS,
|
||||||
|
POLITENESS,
|
||||||
|
)
|
||||||
|
from .base import as_list, safe_div, word_tokens
|
||||||
|
|
||||||
|
_LAUGHTER_RE = re.compile(r"\b(?:ha){2,}\b|\bhehe\b|\bhaha\b|\blol\b", re.IGNORECASE)
|
||||||
|
_EMOTION_WORDS = {
|
||||||
|
"love", "hate", "like", "want", "feel", "think", "know", "happy",
|
||||||
|
"sad", "angry", "afraid", "scared", "excited", "worried", "glad",
|
||||||
|
"sorry", "hope", "wish", "miss", "enjoy", "fun", "nice", "good",
|
||||||
|
"bad", "terrible", "awful", "great", "wonderful",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def extract(row: pd.Series) -> dict[str, float]:
|
||||||
|
text = str(row.get("text", ""))
|
||||||
|
tokens = [str(token).lower() for token in as_list(row, "tokens")]
|
||||||
|
words = word_tokens(tokens)
|
||||||
|
n_words = max(len(words), 1)
|
||||||
|
sentences = as_list(row, "sentences")
|
||||||
|
n_sentences = max(len(sentences), 1)
|
||||||
|
alphabetic = [token for token in tokens if re.search(r"[A-Za-z]", token)]
|
||||||
|
n_alpha = max(len(alphabetic), 1)
|
||||||
|
ellipsis = len(re.findall(r"\.{3,}", text))
|
||||||
|
repeated = len(re.findall(r"[!?]{2,}", text))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"dlg_placeholder_rate": safe_div(float(row.get("placeholder_count", 0)), n_words),
|
||||||
|
"dlg_all_caps_rate": safe_div(
|
||||||
|
sum(1 for token in alphabetic if len(token) > 1 and token.isupper()), n_alpha
|
||||||
|
),
|
||||||
|
"dlg_ellipsis_rate": safe_div(ellipsis, n_sentences),
|
||||||
|
"dlg_repeated_punctuation_rate": safe_div(repeated, n_sentences),
|
||||||
|
"dlg_interjection_rate": safe_div(
|
||||||
|
sum(1 for token in tokens if token in INTERJECTIONS), n_words
|
||||||
|
),
|
||||||
|
"dlg_laughter_rate": safe_div(len(_LAUGHTER_RE.findall(text)), n_sentences),
|
||||||
|
"dlg_discourse_marker_rate": safe_div(
|
||||||
|
sum(1 for token in tokens if token in DISCOURSE_MARKERS), n_words
|
||||||
|
),
|
||||||
|
"dlg_hedge_rate": safe_div(
|
||||||
|
sum(1 for token in tokens if token in HEDGES), n_words
|
||||||
|
),
|
||||||
|
"dlg_intensifier_rate": safe_div(
|
||||||
|
sum(1 for token in tokens if token in INTENSIFIERS), n_words
|
||||||
|
),
|
||||||
|
"dlg_politeness_rate": safe_div(
|
||||||
|
sum(1 for token in tokens if token in POLITENESS), n_words
|
||||||
|
),
|
||||||
|
"dlg_negation_rate": safe_div(
|
||||||
|
sum(1 for token in tokens if token in NEGATIONS), n_words
|
||||||
|
),
|
||||||
|
"dlg_contraction_rate": safe_div(
|
||||||
|
sum(1 for token in tokens if token in CONTRACTIONS or "'" in token), n_words
|
||||||
|
),
|
||||||
|
"dlg_question_tag_rate": safe_div(
|
||||||
|
len(
|
||||||
|
re.findall(
|
||||||
|
r"\b(?:right|isn't it|aren't you|don't you|didn't you|huh)\s*[?.!]?\s*$",
|
||||||
|
text,
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
n_sentences,
|
||||||
|
),
|
||||||
|
"dlg_filler_rate": safe_div(
|
||||||
|
sum(1 for token in tokens if token in {"um", "uh", "hmm", "mmm", "eh"}), n_words
|
||||||
|
),
|
||||||
|
"dlg_emotion_word_rate": safe_div(
|
||||||
|
sum(1 for token in tokens if token in _EMOTION_WORDS), n_words
|
||||||
|
),
|
||||||
|
"dlg_first_person_rate": safe_div(
|
||||||
|
sum(1 for token in tokens if token in {"i", "me", "my", "mine", "myself"}),
|
||||||
|
n_words,
|
||||||
|
),
|
||||||
|
"dlg_second_person_rate": safe_div(
|
||||||
|
sum(1 for token in tokens if token in {"you", "your", "yours", "yourself"}),
|
||||||
|
n_words,
|
||||||
|
),
|
||||||
|
"dlg_one_word_sentence_rate": safe_div(
|
||||||
|
sum(1 for sentence in sentences if len(word_tokens(sentence)) == 1),
|
||||||
|
n_sentences,
|
||||||
|
),
|
||||||
|
}
|
||||||
46
lab1/stylometry/features/function_words.py
Normal file
46
lab1/stylometry/features/function_words.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""Относительные частоты функциональных слов.
|
||||||
|
|
||||||
|
Это ядро классической стилометрии: закрытые классы слов мало зависят от
|
||||||
|
темы, но хорошо отражают авторский или персонажный синтаксис.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from ..lexicons import FUNCTION_WORD_CATEGORIES, FUNCTION_WORDS
|
||||||
|
from .base import as_list, safe_div, word_tokens
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_name(word: str) -> str:
|
||||||
|
return re.sub(r"[^a-z0-9]+", "_", word.lower()).strip("_")
|
||||||
|
|
||||||
|
|
||||||
|
def _sorted_words() -> list[str]:
|
||||||
|
return sorted(FUNCTION_WORDS)
|
||||||
|
|
||||||
|
|
||||||
|
def extract(row: pd.Series) -> dict[str, float]:
|
||||||
|
tokens = [token.lower() for token in word_tokens(as_list(row, "tokens"))]
|
||||||
|
n_words = max(len(tokens), 1)
|
||||||
|
features: dict[str, float] = {}
|
||||||
|
|
||||||
|
for word in _sorted_words():
|
||||||
|
features[f"fw_word_{_safe_name(word)}"] = safe_div(tokens.count(word), n_words)
|
||||||
|
|
||||||
|
for category, vocabulary in FUNCTION_WORD_CATEGORIES.items():
|
||||||
|
count = sum(1 for token in tokens if token in vocabulary)
|
||||||
|
features[f"fw_cat_{category}"] = safe_div(count, n_words)
|
||||||
|
|
||||||
|
features["fw_total_rate"] = safe_div(
|
||||||
|
sum(1 for token in tokens if token in FUNCTION_WORDS), n_words
|
||||||
|
)
|
||||||
|
features["fw_unique_types"] = float(len(set(tokens) & FUNCTION_WORDS))
|
||||||
|
return features
|
||||||
|
|
||||||
|
|
||||||
|
def feature_words() -> list[str]:
|
||||||
|
"""Список слов, для которых создаются отдельные признаки."""
|
||||||
|
return _sorted_words()
|
||||||
48
lab1/stylometry/features/length.py
Normal file
48
lab1/stylometry/features/length.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""Признаки длины и структуры реплики."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from .base import (
|
||||||
|
as_list,
|
||||||
|
finite,
|
||||||
|
mean,
|
||||||
|
median,
|
||||||
|
safe_div,
|
||||||
|
sentence_word_counts,
|
||||||
|
std,
|
||||||
|
word_lengths,
|
||||||
|
word_tokens,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def extract(row: pd.Series) -> dict[str, float]:
|
||||||
|
tokens = as_list(row, "tokens")
|
||||||
|
words = word_tokens(tokens)
|
||||||
|
lengths = word_lengths(words)
|
||||||
|
sentence_lengths = sentence_word_counts(row)
|
||||||
|
n_sentences = max(len(sentence_lengths), 1)
|
||||||
|
text = str(row.get("text", ""))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"len_tokens": float(len(tokens)),
|
||||||
|
"len_words": float(len(words)),
|
||||||
|
"len_chars": float(len(text)),
|
||||||
|
"len_sentences": float(n_sentences),
|
||||||
|
"len_mean_sentence_words": finite(mean(sentence_lengths)),
|
||||||
|
"len_std_sentence_words": finite(std(sentence_lengths)),
|
||||||
|
"len_min_sentence_words": float(min(sentence_lengths)) if sentence_lengths else 0.0,
|
||||||
|
"len_max_sentence_words": float(max(sentence_lengths)) if sentence_lengths else 0.0,
|
||||||
|
"len_median_sentence_words": finite(median(sentence_lengths)),
|
||||||
|
"len_pct_sentences_lt5": safe_div(sum(1 for value in sentence_lengths if value < 5), n_sentences),
|
||||||
|
"len_pct_sentences_gt20": safe_div(sum(1 for value in sentence_lengths if value > 20), n_sentences),
|
||||||
|
"len_pct_one_word_sentences": safe_div(sum(1 for value in sentence_lengths if value == 1), n_sentences),
|
||||||
|
"len_mean_word_chars": finite(mean(lengths)),
|
||||||
|
"len_std_word_chars": finite(std(lengths)),
|
||||||
|
"len_median_word_chars": finite(median(lengths)),
|
||||||
|
"len_pct_words_ge7": safe_div(sum(1 for value in lengths if value >= 7), len(lengths)),
|
||||||
|
"len_pct_words_ge10": safe_div(sum(1 for value in lengths if value >= 10), len(lengths)),
|
||||||
|
"len_pct_words_ge13": safe_div(sum(1 for value in lengths if value >= 13), len(lengths)),
|
||||||
|
"len_pct_single_letter_words": safe_div(sum(1 for value in lengths if value == 1), len(lengths)),
|
||||||
|
}
|
||||||
48
lab1/stylometry/features/lexical.py
Normal file
48
lab1/stylometry/features/lexical.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""Лексические признаки: длины слов и орфографические особенности."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from .base import as_list, safe_div, word_lengths, word_tokens
|
||||||
|
|
||||||
|
|
||||||
|
def extract(row: pd.Series) -> dict[str, float]:
|
||||||
|
tokens = as_list(row, "tokens")
|
||||||
|
words = word_tokens(tokens)
|
||||||
|
lengths = word_lengths(words)
|
||||||
|
total = max(len(lengths), 1)
|
||||||
|
|
||||||
|
features: dict[str, float] = {}
|
||||||
|
for size in range(1, 10):
|
||||||
|
features[f"lex_word_length_{size}"] = safe_div(
|
||||||
|
sum(1 for value in lengths if value == size), total
|
||||||
|
)
|
||||||
|
features["lex_word_length_10plus"] = safe_div(
|
||||||
|
sum(1 for value in lengths if value >= 10), total
|
||||||
|
)
|
||||||
|
|
||||||
|
alphabetic = [token for token in tokens if re.search(r"[A-Za-z]", token)]
|
||||||
|
n_alpha = max(len(alphabetic), 1)
|
||||||
|
features.update(
|
||||||
|
{
|
||||||
|
"lex_pct_apostrophe_words": safe_div(
|
||||||
|
sum(1 for token in alphabetic if "'" in token), n_alpha
|
||||||
|
),
|
||||||
|
"lex_pct_capitalized_words": safe_div(
|
||||||
|
sum(1 for token in alphabetic if token[:1].isupper()), n_alpha
|
||||||
|
),
|
||||||
|
"lex_pct_all_caps_words": safe_div(
|
||||||
|
sum(1 for token in alphabetic if len(token) > 1 and token.isupper()), n_alpha
|
||||||
|
),
|
||||||
|
"lex_pct_hyphenated_words": safe_div(
|
||||||
|
sum(1 for token in alphabetic if "-" in token), n_alpha
|
||||||
|
),
|
||||||
|
"lex_pct_digit_words": safe_div(
|
||||||
|
sum(1 for token in alphabetic if any(char.isdigit() for char in token)), n_alpha
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return features
|
||||||
61
lab1/stylometry/features/pos_features.py
Normal file
61
lab1/stylometry/features/pos_features.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"""Частеречные признаки на основе NLTK.
|
||||||
|
|
||||||
|
Используются две системы тегов: Universal POS (крупные классы) и Penn
|
||||||
|
Treebank (более дробные). Дополнительно считаются отношения и отдельные
|
||||||
|
грамматические категории.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from ..lexicons import MODALS
|
||||||
|
from ..tokenization import ptb_tag_list, upos_tag_list
|
||||||
|
from .base import as_list, safe_div, word_tokens
|
||||||
|
|
||||||
|
|
||||||
|
def extract(row: pd.Series) -> dict[str, float]:
|
||||||
|
tokens = as_list(row, "tokens")
|
||||||
|
upos = as_list(row, "upos")
|
||||||
|
ptb = as_list(row, "ptb")
|
||||||
|
lower = [str(token).lower() for token in tokens]
|
||||||
|
n_words = max(len(word_tokens(tokens)), 1)
|
||||||
|
|
||||||
|
features: dict[str, float] = {}
|
||||||
|
for tag in upos_tag_list():
|
||||||
|
features[f"pos_upos_{tag}"] = safe_div(sum(1 for value in upos if value == tag), n_words)
|
||||||
|
for tag in ptb_tag_list():
|
||||||
|
features[f"pos_ptb_{tag}"] = safe_div(sum(1 for value in ptb if value == tag), n_words)
|
||||||
|
|
||||||
|
noun = sum(1 for value in upos if value == "NOUN")
|
||||||
|
verb = sum(1 for value in upos if value == "VERB")
|
||||||
|
adjective = sum(1 for value in upos if value == "ADJ")
|
||||||
|
adverb = sum(1 for value in upos if value == "ADV")
|
||||||
|
pronoun = sum(1 for value in upos if value == "PRON")
|
||||||
|
|
||||||
|
features.update(
|
||||||
|
{
|
||||||
|
"pos_noun_verb_ratio": safe_div(noun + 1.0, verb + 1.0),
|
||||||
|
"pos_adj_adv_ratio": safe_div(adjective + 1.0, adverb + 1.0),
|
||||||
|
"pos_modal_rate": safe_div(sum(1 for token in lower if token in MODALS), n_words),
|
||||||
|
"pos_aux_rate": safe_div(sum(1 for value in upos if value == "AUX"), n_words),
|
||||||
|
"pos_past_tense_rate": safe_div(sum(1 for value in ptb if value == "VBD"), n_words),
|
||||||
|
"pos_present_tense_rate": safe_div(
|
||||||
|
sum(1 for value in ptb if value in {"VBP", "VBZ"}), n_words
|
||||||
|
),
|
||||||
|
"pos_gerund_rate": safe_div(sum(1 for value in ptb if value == "VBG"), n_words),
|
||||||
|
"pos_participle_rate": safe_div(sum(1 for value in ptb if value == "VBN"), n_words),
|
||||||
|
"pos_comparative_rate": safe_div(
|
||||||
|
sum(1 for value in ptb if value in {"JJR", "RBR"}), n_words
|
||||||
|
),
|
||||||
|
"pos_superlative_rate": safe_div(
|
||||||
|
sum(1 for value in ptb if value in {"JJS", "RBS"}), n_words
|
||||||
|
),
|
||||||
|
"pos_pronoun_rate": safe_div(pronoun, n_words),
|
||||||
|
"pos_content_word_rate": safe_div(
|
||||||
|
sum(1 for value in upos if value in {"NOUN", "VERB", "ADJ", "ADV", "NUM"}),
|
||||||
|
n_words,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return features
|
||||||
45
lab1/stylometry/features/pronouns.py
Normal file
45
lab1/stylometry/features/pronouns.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""Признаки местоимений и референции к говорящему/собеседнику."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from ..lexicons import PRONOUN_CATEGORIES, WH_WORDS
|
||||||
|
from .base import as_list, safe_div, word_tokens
|
||||||
|
|
||||||
|
|
||||||
|
def extract(row: pd.Series) -> dict[str, float]:
|
||||||
|
tokens = [token.lower() for token in word_tokens(as_list(row, "tokens"))]
|
||||||
|
n_words = max(len(tokens), 1)
|
||||||
|
features: dict[str, float] = {}
|
||||||
|
|
||||||
|
for category, vocabulary in PRONOUN_CATEGORIES.items():
|
||||||
|
features[f"pron_{category}_rate"] = safe_div(
|
||||||
|
sum(1 for token in tokens if token in vocabulary), n_words
|
||||||
|
)
|
||||||
|
|
||||||
|
first_singular = sum(
|
||||||
|
1 for token in tokens if token in PRONOUN_CATEGORIES["first_singular"]
|
||||||
|
)
|
||||||
|
second = sum(1 for token in tokens if token in PRONOUN_CATEGORIES["second"])
|
||||||
|
features["pron_i_you_log_ratio"] = math.log((first_singular + 1.0) / (second + 1.0))
|
||||||
|
features["pron_first_person_rate"] = safe_div(
|
||||||
|
first_singular
|
||||||
|
+ sum(1 for token in tokens if token in PRONOUN_CATEGORIES["first_plural"]),
|
||||||
|
n_words,
|
||||||
|
)
|
||||||
|
features["pron_third_person_rate"] = safe_div(
|
||||||
|
sum(
|
||||||
|
1
|
||||||
|
for token in tokens
|
||||||
|
if token in PRONOUN_CATEGORIES["third_singular"]
|
||||||
|
or token in PRONOUN_CATEGORIES["third_plural"]
|
||||||
|
),
|
||||||
|
n_words,
|
||||||
|
)
|
||||||
|
features["pron_wh_rate"] = safe_div(
|
||||||
|
sum(1 for token in tokens if token in WH_WORDS), n_words
|
||||||
|
)
|
||||||
|
return features
|
||||||
57
lab1/stylometry/features/punctuation.py
Normal file
57
lab1/stylometry/features/punctuation.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""Признаки пунктуации и оформления высказывания."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from .base import as_list, safe_div, word_tokens
|
||||||
|
|
||||||
|
|
||||||
|
def extract(row: pd.Series) -> dict[str, float]:
|
||||||
|
text = str(row.get("text", ""))
|
||||||
|
sentences = as_list(row, "sentences")
|
||||||
|
n_sentences = max(len(sentences), 1)
|
||||||
|
n_words = max(len(word_tokens(as_list(row, "tokens"))), 1)
|
||||||
|
|
||||||
|
ellipsis_count = len(re.findall(r"\.{3,}", text))
|
||||||
|
single_periods = len(re.findall(r"(?<!\.)\.(?!\.)", text))
|
||||||
|
exclamations = text.count("!")
|
||||||
|
questions = text.count("?")
|
||||||
|
commas = text.count(",")
|
||||||
|
colons = text.count(":")
|
||||||
|
semicolons = text.count(";")
|
||||||
|
apostrophes = text.count("'")
|
||||||
|
quotes = text.count('"')
|
||||||
|
dashes = text.count("-")
|
||||||
|
parentheses = text.count("(") + text.count(")")
|
||||||
|
multi_exclamation = len(re.findall(r"!{2,}", text))
|
||||||
|
multi_question = len(re.findall(r"\?{2,}", text))
|
||||||
|
mixed = len(re.findall(r"[!?]{2,}", text))
|
||||||
|
|
||||||
|
sentence_texts = [" ".join(sentence) for sentence in sentences if sentence]
|
||||||
|
question_sentences = sum(1 for sentence in sentence_texts if sentence.rstrip().endswith("?"))
|
||||||
|
exclamation_sentences = sum(1 for sentence in sentence_texts if sentence.rstrip().endswith("!"))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"punct_comma_per_sentence": safe_div(commas, n_sentences),
|
||||||
|
"punct_period_per_sentence": safe_div(single_periods, n_sentences),
|
||||||
|
"punct_exclamation_per_sentence": safe_div(exclamations, n_sentences),
|
||||||
|
"punct_question_per_sentence": safe_div(questions, n_sentences),
|
||||||
|
"punct_colon_per_sentence": safe_div(colons, n_sentences),
|
||||||
|
"punct_semicolon_per_sentence": safe_div(semicolons, n_sentences),
|
||||||
|
"punct_ellipsis_per_sentence": safe_div(ellipsis_count, n_sentences),
|
||||||
|
"punct_apostrophe_per_sentence": safe_div(apostrophes, n_sentences),
|
||||||
|
"punct_quote_per_sentence": safe_div(quotes, n_sentences),
|
||||||
|
"punct_dash_per_sentence": safe_div(dashes, n_sentences),
|
||||||
|
"punct_parentheses_per_sentence": safe_div(parentheses, n_sentences),
|
||||||
|
"punct_multi_exclamation_per_sentence": safe_div(multi_exclamation, n_sentences),
|
||||||
|
"punct_multi_question_per_sentence": safe_div(multi_question, n_sentences),
|
||||||
|
"punct_mixed_per_sentence": safe_div(mixed, n_sentences),
|
||||||
|
"punct_question_sentence_rate": safe_div(question_sentences, n_sentences),
|
||||||
|
"punct_exclamation_sentence_rate": safe_div(exclamation_sentences, n_sentences),
|
||||||
|
"punct_comma_per_token": safe_div(commas, n_words),
|
||||||
|
"punct_ellipsis_per_token": safe_div(ellipsis_count, n_words),
|
||||||
|
"punct_apostrophe_per_token": safe_div(apostrophes, n_words),
|
||||||
|
}
|
||||||
66
lab1/stylometry/features/readability.py
Normal file
66
lab1/stylometry/features/readability.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"""Метрики читаемости.
|
||||||
|
|
||||||
|
Формулы адаптированы для коротких разговорных реплик, поэтому значения
|
||||||
|
следует рассматривать как относительные признаки, а не как точные оценки
|
||||||
|
сложности текста. Число слогов вычисляется эвристически.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from .base import as_list, finite, safe_div, sentence_word_counts, word_tokens
|
||||||
|
|
||||||
|
|
||||||
|
def count_syllables(word: str) -> int:
|
||||||
|
"""Эвристическая оценка числа слогов в английском слове."""
|
||||||
|
word = re.sub(r"[^a-z]", "", word.lower())
|
||||||
|
if not word:
|
||||||
|
return 0
|
||||||
|
if len(word) <= 3:
|
||||||
|
return 1
|
||||||
|
if word.endswith("e") and not word.endswith("le"):
|
||||||
|
word = word[:-1]
|
||||||
|
groups = re.findall(r"[aeiouy]+", word)
|
||||||
|
return max(len(groups), 1)
|
||||||
|
|
||||||
|
|
||||||
|
def extract(row: pd.Series) -> dict[str, float]:
|
||||||
|
tokens = word_tokens(as_list(row, "tokens"))
|
||||||
|
words = [re.sub(r"[^A-Za-z]", "", token).lower() for token in tokens]
|
||||||
|
words = [word for word in words if word]
|
||||||
|
n_words = max(len(words), 1)
|
||||||
|
sentence_lengths = sentence_word_counts(row)
|
||||||
|
n_sentences = max(len(sentence_lengths), 1)
|
||||||
|
syllables = [count_syllables(word) for word in words]
|
||||||
|
n_syllables = sum(syllables)
|
||||||
|
polysyllables = sum(1 for value in syllables if value >= 3)
|
||||||
|
n_letters = sum(len(word) for word in words)
|
||||||
|
|
||||||
|
words_per_sentence = safe_div(n_words, n_sentences)
|
||||||
|
syllables_per_word = safe_div(n_syllables, n_words)
|
||||||
|
polysyllable_rate = safe_div(polysyllables, n_words)
|
||||||
|
letters_per_word = safe_div(n_letters, n_words)
|
||||||
|
|
||||||
|
flesch = 206.835 - 1.015 * words_per_sentence - 84.6 * syllables_per_word
|
||||||
|
fk_grade = 0.39 * words_per_sentence + 11.8 * syllables_per_word - 15.59
|
||||||
|
gunning_fog = 0.4 * (words_per_sentence + 100.0 * polysyllable_rate)
|
||||||
|
smog = 1.0430 * (30.0 * safe_div(polysyllables, n_sentences)) ** 0.5 + 3.1291
|
||||||
|
coleman_liau = 0.0588 * (letters_per_word * 100.0) - 0.296 * (
|
||||||
|
safe_div(n_sentences, n_words) * 100.0
|
||||||
|
) - 15.8
|
||||||
|
ari = 4.71 * letters_per_word + 0.5 * words_per_sentence - 21.43
|
||||||
|
|
||||||
|
return {
|
||||||
|
"read_syllables_per_word": finite(syllables_per_word),
|
||||||
|
"read_polysyllable_rate": finite(polysyllable_rate),
|
||||||
|
"read_flesch": finite(flesch),
|
||||||
|
"read_fk_grade": finite(fk_grade),
|
||||||
|
"read_gunning_fog": finite(gunning_fog),
|
||||||
|
"read_smog": finite(smog),
|
||||||
|
"read_coleman_liau": finite(coleman_liau),
|
||||||
|
"read_ari": finite(ari),
|
||||||
|
"read_words_per_sentence": finite(words_per_sentence),
|
||||||
|
}
|
||||||
192
lab1/stylometry/features/stylometry.py
Normal file
192
lab1/stylometry/features/stylometry.py
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
"""Агрегатор ручных стилометрических признаков.
|
||||||
|
|
||||||
|
Класс не обучается на данных: все формулы фиксированы. Поэтому его можно
|
||||||
|
безопасно применять ко всей таблице до кросс-валидации. Обучаемые
|
||||||
|
преобразования (TF-IDF, SVD, масштабирование) вынесены в другие классы и
|
||||||
|
настраиваются только на обучающих блоках.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Callable, Iterable, Sequence
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from sklearn.base import BaseEstimator, TransformerMixin
|
||||||
|
|
||||||
|
from . import (
|
||||||
|
characters,
|
||||||
|
dialogue,
|
||||||
|
function_words,
|
||||||
|
length,
|
||||||
|
lexical,
|
||||||
|
pos_features,
|
||||||
|
pronouns,
|
||||||
|
punctuation,
|
||||||
|
readability,
|
||||||
|
syntax,
|
||||||
|
vocabulary,
|
||||||
|
)
|
||||||
|
|
||||||
|
FEATURE_GROUPS: dict[str, Callable[[pd.Series], dict[str, float]]] = {
|
||||||
|
"length": length.extract,
|
||||||
|
"lexical": lexical.extract,
|
||||||
|
"vocabulary": vocabulary.extract,
|
||||||
|
"characters": characters.extract,
|
||||||
|
"punctuation": punctuation.extract,
|
||||||
|
"function_words": function_words.extract,
|
||||||
|
"pronouns": pronouns.extract,
|
||||||
|
"pos": pos_features.extract,
|
||||||
|
"syntax": syntax.extract,
|
||||||
|
"readability": readability.extract,
|
||||||
|
"dialogue": dialogue.extract,
|
||||||
|
}
|
||||||
|
|
||||||
|
FEATURE_GROUP_PREFIXES: dict[str, str] = {
|
||||||
|
"length": "len_",
|
||||||
|
"lexical": "lex_",
|
||||||
|
"vocabulary": "vocab_",
|
||||||
|
"characters": "char_",
|
||||||
|
"punctuation": "punct_",
|
||||||
|
"function_words": "fw_",
|
||||||
|
"pronouns": "pron_",
|
||||||
|
"pos": "pos_",
|
||||||
|
"syntax": "syn_",
|
||||||
|
"readability": "read_",
|
||||||
|
"dialogue": "dlg_",
|
||||||
|
}
|
||||||
|
|
||||||
|
EXPLORATORY_GROUPS = {"vocabulary", "readability"}
|
||||||
|
|
||||||
|
|
||||||
|
def default_groups() -> list[str]:
|
||||||
|
return list(FEATURE_GROUPS)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_groups(groups: Sequence[str]) -> list[str]:
|
||||||
|
unknown = [group for group in groups if group not in FEATURE_GROUPS]
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(
|
||||||
|
f"Неизвестные группы признаков: {unknown}. "
|
||||||
|
f"Доступны: {sorted(FEATURE_GROUPS)}"
|
||||||
|
)
|
||||||
|
return list(groups)
|
||||||
|
|
||||||
|
|
||||||
|
class StylometricVectorizer(BaseEstimator, TransformerMixin):
|
||||||
|
"""Считает матрицу ручных признаков по таблице реплик.
|
||||||
|
|
||||||
|
Ожидает DataFrame с колонками ``tokens``, ``upos``, ``ptb``, ``sentences``,
|
||||||
|
``text`` и ``placeholder_count``. Такие колонки создаёт
|
||||||
|
:class:`stylometry.tokenization.Tokenizer`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, groups: Sequence[str] | None = None) -> None:
|
||||||
|
self.groups = groups
|
||||||
|
|
||||||
|
def _selected_groups(self) -> list[str]:
|
||||||
|
groups = self.groups if self.groups is not None else default_groups()
|
||||||
|
return _validate_groups(groups)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_row(row: pd.Series, groups: Sequence[str]) -> dict[str, float]:
|
||||||
|
features: dict[str, float] = {}
|
||||||
|
for group in groups:
|
||||||
|
features.update(FEATURE_GROUPS[group](row))
|
||||||
|
return features
|
||||||
|
|
||||||
|
def fit(self, X: pd.DataFrame, y=None) -> "StylometricVectorizer":
|
||||||
|
if not isinstance(X, pd.DataFrame):
|
||||||
|
raise TypeError("StylometricVectorizer ожидает pandas.DataFrame.")
|
||||||
|
if len(X) == 0:
|
||||||
|
raise ValueError("Пустая таблица признаков.")
|
||||||
|
groups = self._selected_groups()
|
||||||
|
sample = self._extract_row(X.iloc[0], groups)
|
||||||
|
self.feature_names_in_ = np.array(list(sample.keys()), dtype=object)
|
||||||
|
self.n_features_in_ = len(self.feature_names_in_)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def transform(self, X: pd.DataFrame) -> np.ndarray:
|
||||||
|
if not isinstance(X, pd.DataFrame):
|
||||||
|
raise TypeError("StylometricVectorizer ожидает pandas.DataFrame.")
|
||||||
|
groups = self._selected_groups()
|
||||||
|
rows: list[list[float]] = []
|
||||||
|
names: list[str] | None = None
|
||||||
|
for _, row in X.iterrows():
|
||||||
|
features = self._extract_row(row, groups)
|
||||||
|
if names is None:
|
||||||
|
names = list(features.keys())
|
||||||
|
rows.append([float(features.get(name, 0.0)) for name in names])
|
||||||
|
if names is None:
|
||||||
|
raise ValueError("Не удалось вычислить признаки: таблица пуста.")
|
||||||
|
self.feature_names_in_ = np.array(names, dtype=object)
|
||||||
|
self.n_features_in_ = len(names)
|
||||||
|
return np.asarray(rows, dtype=float)
|
||||||
|
|
||||||
|
def get_feature_names_out(self, input_features=None) -> np.ndarray:
|
||||||
|
if not hasattr(self, "feature_names_in_"):
|
||||||
|
raise RuntimeError("Сначала вызовите fit или transform.")
|
||||||
|
return self.feature_names_in_
|
||||||
|
|
||||||
|
|
||||||
|
def feature_group(feature_name: str) -> str:
|
||||||
|
"""Определяет группу признака по его префиксу."""
|
||||||
|
for group, prefix in FEATURE_GROUP_PREFIXES.items():
|
||||||
|
if feature_name.startswith(prefix):
|
||||||
|
return group
|
||||||
|
return "other"
|
||||||
|
|
||||||
|
|
||||||
|
def describe_feature(feature_name: str) -> str:
|
||||||
|
"""Возвращает краткое описание признака для словаря и отчёта."""
|
||||||
|
group = feature_group(feature_name)
|
||||||
|
if group == "function_words":
|
||||||
|
if feature_name.startswith("fw_word_"):
|
||||||
|
word = feature_name.removeprefix("fw_word_").replace("_", " ")
|
||||||
|
return f"Относительная частота функционального слова «{word}»."
|
||||||
|
if feature_name.startswith("fw_cat_"):
|
||||||
|
category = feature_name.removeprefix("fw_cat_")
|
||||||
|
return f"Относительная частота категории функциональных слов «{category}»."
|
||||||
|
return "Суммарная относительная частота функциональных слов."
|
||||||
|
if group == "characters" and feature_name.startswith("char_freq_"):
|
||||||
|
letter = feature_name.removeprefix("char_freq_")
|
||||||
|
return f"Относительная частота буквы «{letter}» среди латинских букв."
|
||||||
|
if group == "pos" and feature_name.startswith("pos_upos_"):
|
||||||
|
tag = feature_name.removeprefix("pos_upos_")
|
||||||
|
return f"Доля универсального частеречного тега {tag}."
|
||||||
|
if group == "pos" and feature_name.startswith("pos_ptb_"):
|
||||||
|
tag = feature_name.removeprefix("pos_ptb_")
|
||||||
|
return f"Доля тега Penn Treebank {tag}."
|
||||||
|
if group == "lexical" and feature_name.startswith("lex_word_length_"):
|
||||||
|
size = feature_name.removeprefix("lex_word_length_")
|
||||||
|
return f"Доля слов длины {size} (в символах)."
|
||||||
|
descriptions = {
|
||||||
|
"length": "Признак длины или структуры реплики.",
|
||||||
|
"lexical": "Лексический признак длины слова или орфографии.",
|
||||||
|
"vocabulary": "Признак словарного богатства; нестабилен на коротких текстах.",
|
||||||
|
"characters": "Символьный признак: регистр, цифры, пунктуация, буквы.",
|
||||||
|
"punctuation": "Признак пунктуационного оформления.",
|
||||||
|
"function_words": "Признак функциональных слов.",
|
||||||
|
"pronouns": "Признак местоимений и референции.",
|
||||||
|
"pos": "Частеречный признак.",
|
||||||
|
"syntax": "Синтаксический признак-прокси.",
|
||||||
|
"readability": "Метрика читаемости; на коротких репликах приблизительна.",
|
||||||
|
"dialogue": "Признак разговорной речи и игровых реплик.",
|
||||||
|
}
|
||||||
|
return descriptions.get(group, "Стилометрический признак.")
|
||||||
|
|
||||||
|
|
||||||
|
def feature_dictionary(feature_names: Iterable[str]) -> pd.DataFrame:
|
||||||
|
"""Строит таблицу с описанием признаков для отчёта."""
|
||||||
|
rows = []
|
||||||
|
for name in feature_names:
|
||||||
|
group = feature_group(name)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"feature": name,
|
||||||
|
"group": group,
|
||||||
|
"description": describe_feature(name),
|
||||||
|
"status": "exploratory" if group in EXPLORATORY_GROUPS else "core",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return pd.DataFrame(rows)
|
||||||
112
lab1/stylometry/features/syntax.py
Normal file
112
lab1/stylometry/features/syntax.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"""Синтаксические признаки-прокси.
|
||||||
|
|
||||||
|
Полноценный синтаксический анализ не используется: для коротких реплик он
|
||||||
|
нестабилен и требует тяжёлых моделей. Вместо этого считаются устойчивые
|
||||||
|
приближения по функциям слов, длине предложений и окончаниям.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from ..lexicons import COORDINATORS, MODALS, NEGATIONS, SUBORDINATORS
|
||||||
|
from .base import as_list, safe_div, sentence_word_counts, word_tokens
|
||||||
|
|
||||||
|
_IMPERATIVE_VERBS = {
|
||||||
|
"go", "come", "look", "take", "get", "give", "tell", "let", "stop",
|
||||||
|
"wait", "listen", "try", "keep", "put", "make", "do", "be", "have",
|
||||||
|
"see", "say", "help", "leave", "stay", "bring", "show", "find",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sentence_first_words(row: pd.Series) -> list[str]:
|
||||||
|
sentences = as_list(row, "sentences")
|
||||||
|
first_words: list[str] = []
|
||||||
|
for sentence in sentences:
|
||||||
|
for token in sentence:
|
||||||
|
if re.search(r"[A-Za-z]", token):
|
||||||
|
first_words.append(token.lower())
|
||||||
|
break
|
||||||
|
return first_words
|
||||||
|
|
||||||
|
|
||||||
|
def extract(row: pd.Series) -> dict[str, float]:
|
||||||
|
tokens = [str(token).lower() for token in as_list(row, "tokens")]
|
||||||
|
words = word_tokens(tokens)
|
||||||
|
n_words = max(len(words), 1)
|
||||||
|
sentences = as_list(row, "sentences")
|
||||||
|
n_sentences = max(len(sentences), 1)
|
||||||
|
sentence_lengths = sentence_word_counts(row)
|
||||||
|
first_words = _sentence_first_words(row)
|
||||||
|
text = str(row.get("text", ""))
|
||||||
|
|
||||||
|
coordinators = sum(1 for token in tokens if token in COORDINATORS)
|
||||||
|
subordinators = sum(1 for token in tokens if token in SUBORDINATORS)
|
||||||
|
clauses = coordinators + subordinators
|
||||||
|
|
||||||
|
passive = len(
|
||||||
|
re.findall(
|
||||||
|
r"\b(?:be|is|are|was|were|been|being)\s+"
|
||||||
|
r"(?:\w+ed|known|made|given|taken|seen|done|written|spoken)\b",
|
||||||
|
text,
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
question_tags = len(
|
||||||
|
re.findall(
|
||||||
|
r"\b(?:right|isn't it|aren't you|don't you|didn't you|huh)\s*[?.!]?\s*$",
|
||||||
|
text,
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
imperative = sum(1 for word in first_words if word in _IMPERATIVE_VERBS)
|
||||||
|
sentence_initial_conjunction = sum(
|
||||||
|
1 for word in first_words if word in COORDINATORS or word in SUBORDINATORS
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"syn_mean_clauses_per_sentence": safe_div(clauses, n_sentences),
|
||||||
|
"syn_coordinator_per_sentence": safe_div(coordinators, n_sentences),
|
||||||
|
"syn_subordinator_per_sentence": safe_div(subordinators, n_sentences),
|
||||||
|
"syn_sentence_initial_conjunction_rate": safe_div(
|
||||||
|
sentence_initial_conjunction, n_sentences
|
||||||
|
),
|
||||||
|
"syn_question_rate": safe_div(
|
||||||
|
sum(
|
||||||
|
1
|
||||||
|
for sentence in sentences
|
||||||
|
if sentence and " ".join(sentence).rstrip().endswith("?")
|
||||||
|
),
|
||||||
|
n_sentences,
|
||||||
|
),
|
||||||
|
"syn_exclamation_rate": safe_div(
|
||||||
|
sum(
|
||||||
|
1
|
||||||
|
for sentence in sentences
|
||||||
|
if sentence and " ".join(sentence).rstrip().endswith("!")
|
||||||
|
),
|
||||||
|
n_sentences,
|
||||||
|
),
|
||||||
|
"syn_modal_rate": safe_div(sum(1 for token in tokens if token in MODALS), n_words),
|
||||||
|
"syn_negation_per_sentence": safe_div(
|
||||||
|
sum(1 for token in tokens if token in NEGATIONS), n_sentences
|
||||||
|
),
|
||||||
|
"syn_that_rate": safe_div(tokens.count("that"), n_words),
|
||||||
|
"syn_to_rate": safe_div(tokens.count("to"), n_words),
|
||||||
|
"syn_ing_rate": safe_div(
|
||||||
|
sum(1 for token in words if token.endswith("ing")), n_words
|
||||||
|
),
|
||||||
|
"syn_ed_rate": safe_div(
|
||||||
|
sum(1 for token in words if token.endswith("ed")), n_words
|
||||||
|
),
|
||||||
|
"syn_ly_rate": safe_div(
|
||||||
|
sum(1 for token in words if token.endswith("ly")), n_words
|
||||||
|
),
|
||||||
|
"syn_passive_proxy_per_sentence": safe_div(passive, n_sentences),
|
||||||
|
"syn_question_tag_per_sentence": safe_div(question_tags, n_sentences),
|
||||||
|
"syn_imperative_proxy_rate": safe_div(imperative, n_sentences),
|
||||||
|
"syn_mean_sentence_words": safe_div(sum(sentence_lengths), n_sentences),
|
||||||
|
"syn_max_sentence_words": float(max(sentence_lengths)) if sentence_lengths else 0.0,
|
||||||
|
}
|
||||||
67
lab1/stylometry/features/vocabulary.py
Normal file
67
lab1/stylometry/features/vocabulary.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""Признаки словарного богатства.
|
||||||
|
|
||||||
|
На коротких репликах эти признаки нестабильны. Они включены как
|
||||||
|
дополнительная группа и должны проверяться отдельно в ablation-эксперименте.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from .base import as_list, finite, safe_div, word_tokens
|
||||||
|
|
||||||
|
|
||||||
|
def _yule_k(counter: Counter[str], n_tokens: int) -> float:
|
||||||
|
if n_tokens <= 1:
|
||||||
|
return 0.0
|
||||||
|
frequency_of_frequencies = Counter(counter.values())
|
||||||
|
second_moment = sum(
|
||||||
|
frequency * frequency * count
|
||||||
|
for frequency, count in frequency_of_frequencies.items()
|
||||||
|
)
|
||||||
|
return finite(10_000.0 * (second_moment - n_tokens) / (n_tokens * n_tokens))
|
||||||
|
|
||||||
|
|
||||||
|
def _simpson_d(counter: Counter[str], n_tokens: int) -> float:
|
||||||
|
if n_tokens <= 1:
|
||||||
|
return 0.0
|
||||||
|
numerator = sum(count * (count - 1) for count in counter.values())
|
||||||
|
return finite(numerator / (n_tokens * (n_tokens - 1)))
|
||||||
|
|
||||||
|
|
||||||
|
def extract(row: pd.Series) -> dict[str, float]:
|
||||||
|
tokens = [token.lower() for token in word_tokens(as_list(row, "tokens"))]
|
||||||
|
counter = Counter(tokens)
|
||||||
|
n_tokens = len(tokens)
|
||||||
|
n_types = len(counter)
|
||||||
|
hapax = sum(1 for count in counter.values() if count == 1)
|
||||||
|
dis = sum(1 for count in counter.values() if count == 2)
|
||||||
|
|
||||||
|
ttr = safe_div(n_types, n_tokens)
|
||||||
|
guiraud = safe_div(n_types, math.sqrt(n_tokens))
|
||||||
|
herdan = finite(math.log(n_types) / math.log(n_tokens)) if n_tokens > 1 and n_types > 1 else 0.0
|
||||||
|
hapax_ratio = safe_div(hapax, n_types)
|
||||||
|
dis_ratio = safe_div(dis, n_types)
|
||||||
|
if n_types > 0 and hapax_ratio < 1.0:
|
||||||
|
honore = finite(100.0 * math.log(n_tokens) / (1.0 - hapax_ratio))
|
||||||
|
else:
|
||||||
|
honore = 0.0
|
||||||
|
|
||||||
|
top10 = sum(count for _, count in counter.most_common(10))
|
||||||
|
return {
|
||||||
|
"vocab_tokens": float(n_tokens),
|
||||||
|
"vocab_types": float(n_types),
|
||||||
|
"vocab_ttr": ttr,
|
||||||
|
"vocab_guiraud": finite(guiraud),
|
||||||
|
"vocab_herdan": herdan,
|
||||||
|
"vocab_yule_k": _yule_k(counter, n_tokens),
|
||||||
|
"vocab_simpson_d": _simpson_d(counter, n_tokens),
|
||||||
|
"vocab_hapax_ratio": hapax_ratio,
|
||||||
|
"vocab_dis_ratio": dis_ratio,
|
||||||
|
"vocab_honore_r": honore,
|
||||||
|
"vocab_top10_coverage": safe_div(top10, n_tokens),
|
||||||
|
"vocab_mean_word_frequency": safe_div(n_tokens, n_types),
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user