Files
NLP/lab1/stylometry/features/stylometry.py

193 lines
8.3 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.

"""Агрегатор ручных стилометрических признаков.
Класс не обучается на данных: все формулы фиксированы. Поэтому его можно
безопасно применять ко всей таблице до кросс-валидации. Обучаемые
преобразования (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)