Первичная версия: стилометрия реплик, LSA и протокол экспериментов

This commit is contained in:
2026-10-24 01:17:36 +03:00
commit c390224500
52 changed files with 10099 additions and 0 deletions

View File

@@ -0,0 +1,363 @@
"""Токенизация, разбиение на предложения и частеречная разметка.
По умолчанию используется NLTK: ``word_tokenize``, ``sent_tokenize`` и
``pos_tag``. Если ресурсы NLTK недоступны, включается упрощённый резервный
режим на регулярных выражениях, а POS-признаки отключаются. Это позволяет
запускать аудит и часть признаков даже без скачанных моделей.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Sequence
import pandas as pd
from .lexicons import (
AUXILIARIES,
FUNCTION_WORDS,
MODALS,
NEGATIONS,
PERSONAL_PRONOUNS,
SUBORDINATORS,
STOPWORD_FALLBACK,
)
DEFAULT_NLTK_DIR = Path(__file__).resolve().parents[1] / "nltk_data"
_WORD_RE = re.compile(r"[A-Za-z]+(?:['’][A-Za-z]+)?|\d+|[^\w\s]")
_SENTENCE_RE = re.compile(r"(?<=[.!?])\s+")
_PUNCT_TAGS = {".", ",", ":", "``", "''", "-LRB-", "-RRB-", "HYPH", "NFP"}
_PTB_TAGS = [
"CC", "CD", "DT", "EX", "FW", "IN", "JJ", "JJR", "JJS", "LS", "MD",
"NN", "NNS", "NNP", "NNPS", "PDT", "POS", "PRP", "PRP$", "RB",
"RBR", "RBS", "RP", "SYM", "TO", "UH", "VB", "VBD", "VBG", "VBN",
"VBP", "VBZ", "WDT", "WP", "WP$", "WRB",
]
UPOS_TAGS = [
"ADJ", "ADP", "ADV", "AUX", "CONJ", "DET", "INTJ", "NOUN", "NUM",
"PRON", "PRT", "PUNCT", "SCONJ", "SYM", "VERB", "X",
]
_AUX_WORDS = AUXILIARIES | MODALS | {"be", "been", "being", "am", "is", "are", "was", "were"}
_NEGATION_WORDS = NEGATIONS | {"not", "n't"}
def _check_nltk_resource(resource: str) -> bool:
try:
import nltk
nltk.data.find(resource)
return True
except Exception:
return False
def ensure_nltk_data(
nltk_data_dir: str | Path | None = None,
download: bool = False,
) -> bool:
"""Добавляет локальный каталог NLTK в путь поиска и проверяет ресурсы.
Возвращает ``True``, если доступны и токенизатор, и POS-теггер.
"""
import nltk
directory = Path(nltk_data_dir) if nltk_data_dir is not None else DEFAULT_NLTK_DIR
if directory.exists():
path = str(directory.resolve())
if path not in nltk.data.path:
nltk.data.path.insert(0, path)
required = {
"tokenizer": "tokenizers/punkt_tab",
"tagger": "taggers/averaged_perceptron_tagger_eng",
}
if download:
for package in (
"punkt",
"punkt_tab",
"averaged_perceptron_tagger",
"averaged_perceptron_tagger_eng",
"stopwords",
):
nltk.download(package, download_dir=str(directory), quiet=True)
return all(_check_nltk_resource(resource) for resource in required.values())
def load_stopwords(nltk_data_dir: str | Path | None = None) -> set[str]:
"""Загружает английские стоп-слова NLTK или использует запасной список."""
try:
import nltk
directory = Path(nltk_data_dir) if nltk_data_dir is not None else DEFAULT_NLTK_DIR
if directory.exists():
path = str(directory.resolve())
if path not in nltk.data.path:
nltk.data.path.insert(0, path)
return set(nltk.corpus.stopwords.words("english"))
except Exception:
return set(STOPWORD_FALLBACK)
def penn_to_upos(token: str, tag: str) -> str:
"""Переводит тег Penn Treebank в Universal POS.
Часть решений зависит не только от тега, но и от самого слова: например,
``do`` может быть смысловым глаголом или вспомогательным, а ``as`` —
предлогом или подчинительным союзом.
"""
lower = token.lower()
if tag in _PUNCT_TAGS or token in {".", ",", "!", "?", ";", ":", "...", "…"}:
return "PUNCT"
if tag == "SYM":
return "SYM"
if tag == "CC":
return "CONJ"
if tag == "IN":
return "SCONJ" if lower in SUBORDINATORS else "ADP"
if tag == "TO":
return "PRT"
if tag == "RP" or tag == "POS":
return "PRT"
if tag == "MD":
return "AUX"
if tag == "EX":
return "PRON"
if tag in {"WDT", "WP", "WP$"}:
return "PRON"
if tag == "WRB":
return "ADV"
if tag == "UH":
return "INTJ"
if tag == "CD":
return "NUM"
if tag in {"DT", "PDT"}:
return "DET"
if tag in {"PRP", "PRP$"}:
return "PRON"
if tag.startswith("JJ"):
return "ADJ"
if tag.startswith("RB"):
return "ADV"
if tag.startswith("NN"):
return "NOUN"
if tag.startswith("VB"):
if lower in _AUX_WORDS:
return "AUX"
return "VERB"
if tag == "FW":
return "X"
if tag == "LS":
return "X"
# Уточнения по слову для распространённых разговорных форм.
if lower in _NEGATION_WORDS:
return "PRT"
if lower in _AUX_WORDS:
return "AUX"
if lower in PERSONAL_PRONOUNS:
return "PRON"
return "X"
def collapse_punctuation(tags: Sequence[str]) -> list[str]:
"""Схлопывает подряд идущие знаки препинания в один тег PUNCT."""
result: list[str] = []
for tag in tags:
if tag == "PUNCT" and result and result[-1] == "PUNCT":
continue
result.append(tag)
return result
def is_function_word(token: str, upos: str | None = None) -> bool:
"""Определяет, относится ли слово к закрытому классу."""
lower = token.lower()
if lower in FUNCTION_WORDS:
return True
if upos in {"ADP", "AUX", "CONJ", "DET", "PRON", "PRT", "SCONJ"}:
return True
return False
def is_content_word(token: str, upos: str | None = None, stopwords: set[str] | None = None) -> bool:
"""Определяет, является ли слово содержательным для content-LSA."""
lower = token.lower()
if not re.search(r"[A-Za-z]", token):
return False
if is_function_word(token, upos):
return False
if stopwords and lower in stopwords:
return False
return True
@dataclass
class AnalyzedLine:
"""Результат токенизации и разметки одной реплики."""
tokens: list[str] = field(default_factory=list)
ptb: list[str] = field(default_factory=list)
upos: list[str] = field(default_factory=list)
sentences: list[list[str]] = field(default_factory=list)
pos_text: str = ""
fw_text: str = ""
content_text: str = ""
class Tokenizer:
"""Токенизатор с NLTK и резервным режимом."""
def __init__(
self,
use_nltk: bool = True,
nltk_data_dir: str | Path | None = None,
strict: bool = False,
) -> None:
self.use_nltk = bool(use_nltk)
self.nltk_data_dir = Path(nltk_data_dir) if nltk_data_dir is not None else DEFAULT_NLTK_DIR
self.strict = strict
self.stopwords = load_stopwords(self.nltk_data_dir)
if self.use_nltk:
available = ensure_nltk_data(self.nltk_data_dir)
if not available:
message = (
"Ресурсы NLTK не найдены. Скачайте punkt_tab и "
"averaged_perceptron_tagger_eng или запустите с --no-nltk."
)
if self.strict:
raise RuntimeError(message)
print(f"[tokenization] {message} Включаю резервный режим.")
self.use_nltk = False
self._pos_available = self.use_nltk and _check_nltk_resource(
"taggers/averaged_perceptron_tagger_eng"
)
self._word_tokenize = None
self._sent_tokenize = None
self._pos_tag = None
if self.use_nltk:
from nltk import pos_tag
from nltk.tokenize import sent_tokenize, word_tokenize
self._word_tokenize = word_tokenize
self._sent_tokenize = sent_tokenize
self._pos_tag = pos_tag
@property
def pos_available(self) -> bool:
return self._pos_available
def tokenize(self, text: str) -> list[str]:
if self._word_tokenize is not None:
try:
return [token for token in self._word_tokenize(text) if token.strip()]
except Exception:
pass
return _WORD_RE.findall(text)
def sentence_tokenize(self, text: str) -> list[str]:
if self._sent_tokenize is not None:
try:
sentences = [sentence.strip() for sentence in self._sent_tokenize(text)]
return [sentence for sentence in sentences if sentence]
except Exception:
pass
return [part.strip() for part in _SENTENCE_RE.split(text) if part.strip()]
def pos_tag(self, tokens: Sequence[str]) -> list[tuple[str, str]]:
if self._pos_tag is not None:
try:
return list(self._pos_tag(list(tokens)))
except Exception:
pass
return [(token, "X") for token in tokens]
def analyze(self, text: str) -> AnalyzedLine:
tokens = self.tokenize(text)
tagged = self.pos_tag(tokens)
ptb = [tag for _, tag in tagged]
upos = [penn_to_upos(token, tag) for token, tag in tagged]
sentences: list[list[str]] = []
for sentence in self.sentence_tokenize(text):
sentence_tokens = self.tokenize(sentence)
if sentence_tokens:
sentences.append(sentence_tokens)
if not sentences and tokens:
sentences = [tokens]
pos_sequence = collapse_punctuation(upos)
pos_text = " ".join(pos_sequence) if pos_sequence else "__EMPTY__"
fw_tokens = [
token
for token, tag in zip(tokens, upos)
if is_function_word(token, tag)
]
fw_text = " ".join(fw_tokens) if fw_tokens else "__EMPTY__"
content_tokens = [
token
for token, tag in zip(tokens, upos)
if is_content_word(token, tag, self.stopwords)
]
content_text = " ".join(content_tokens) if content_tokens else "__EMPTY__"
return AnalyzedLine(
tokens=tokens,
ptb=ptb,
upos=upos,
sentences=sentences,
pos_text=pos_text,
fw_text=fw_text,
content_text=content_text,
)
def analyze_frame(
self,
frame: pd.DataFrame,
text_column: str = "text",
verbose: bool = False,
) -> pd.DataFrame:
"""Добавляет к таблице колонки с токенами, тегами и текстами для LSA."""
if text_column not in frame.columns:
raise KeyError(f"Нет колонки {text_column!r}")
records: list[dict[str, object]] = []
total = len(frame)
for position, (_, row) in enumerate(frame.iterrows(), start=1):
analyzed = self.analyze(str(row[text_column]))
records.append(
{
"tokens": analyzed.tokens,
"ptb": analyzed.ptb,
"upos": analyzed.upos,
"sentences": analyzed.sentences,
"pos_text": analyzed.pos_text,
"fw_text": analyzed.fw_text,
"content_text": analyzed.content_text,
}
)
if verbose and position % 500 == 0:
print(f"[tokenization] обработано {position} из {total}")
additions = pd.DataFrame(records, index=frame.index)
result = pd.concat([frame.copy(), additions], axis=1)
return result
def upos_tag_list() -> list[str]:
"""Список универсальных частеречных тегов, используемых в признаках."""
return list(UPOS_TAGS)
def ptb_tag_list() -> list[str]:
"""Список тегов Penn Treebank, используемых в признаках."""
return list(_PTB_TAGS)