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