Files
NLP/lab1/stylometry/tokenization.py

364 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Токенизация, разбиение на предложения и частеречная разметка.
По умолчанию используется 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)