"""Сборка признаковых пространств и классификаторов.""" from __future__ import annotations from dataclasses import dataclass, field from typing import Any from sklearn.dummy import DummyClassifier from sklearn.ensemble import ExtraTreesClassifier, RandomForestClassifier from sklearn.linear_model import LogisticRegression from sklearn.model_selection import GridSearchCV from sklearn.naive_bayes import MultinomialNB from sklearn.neighbors import KNeighborsClassifier from sklearn.pipeline import FeatureUnion, Pipeline from sklearn.preprocessing import MinMaxScaler, Normalizer, StandardScaler from sklearn.svm import LinearSVC from .features import FEATURE_GROUPS, StylometricVectorizer from .lsa import ( LSA_VARIANTS, make_char_ngram_vectorizer, make_lsa_vectorizer, make_pos_ngram_vectorizer, ) MODEL_NAMES = ( "dummy", "multinomial_nb", "logreg", "linear_svc", "random_forest", "extra_trees", "knn", ) @dataclass class FeatureConfig: """Описание признакового пространства.""" groups: tuple[str, ...] = field(default_factory=lambda: tuple(FEATURE_GROUPS)) use_char_ngrams: bool = True use_pos_ngrams: bool = True lsa_variants: tuple[str, ...] = ("word", "pos") lsa_components: int = 100 stylo_scaler: str = "standard" # standard | minmax | none stylo_normalize: bool = True include_stylo: bool = True include_lsa: bool = True char_max_features: int = 5000 pos_max_features: int = 500 char_ngram_range: tuple[int, int] = (2, 5) pos_ngram_range: tuple[int, int] = (1, 3) min_df: int = 3 random_state: int = 42 def _stylo_branch(config: FeatureConfig) -> Pipeline: if config.stylo_scaler == "standard": scaler: Any = StandardScaler() elif config.stylo_scaler == "minmax": scaler = MinMaxScaler() elif config.stylo_scaler == "none": scaler = "passthrough" else: raise ValueError(f"Неизвестный масштабировщик: {config.stylo_scaler}") steps: list[tuple[str, Any]] = [ ("extract", StylometricVectorizer(groups=config.groups)), ("scale", scaler), ] if config.stylo_normalize: # L2-нормировка уравнивает вклад блоков разной размерности. steps.append(("norm", Normalizer())) return Pipeline(steps) def build_feature_union(config: FeatureConfig) -> Any: """Собирает блоки признаков в единое пространство.""" branches: list[tuple[str, Any]] = [] if config.include_stylo: branches.append(("stylo", _stylo_branch(config))) if config.use_char_ngrams: branches.append( ( "char", Pipeline( [ ( "tfidf", make_char_ngram_vectorizer( ngram_range=config.char_ngram_range, max_features=config.char_max_features, min_df=config.min_df, ), ), ("norm", Normalizer()), ] ), ) ) if config.use_pos_ngrams: branches.append( ( "pos", Pipeline( [ ( "tfidf", make_pos_ngram_vectorizer( ngram_range=config.pos_ngram_range, max_features=config.pos_max_features, min_df=config.min_df, ), ), ("norm", Normalizer()), ] ), ) ) if config.include_lsa: for variant in config.lsa_variants: if variant not in LSA_VARIANTS: raise ValueError(f"Неизвестный вариант LSA: {variant}") branches.append( ( f"lsa_{variant}", Pipeline( [ ( "lsa", make_lsa_vectorizer( variant=variant, n_components=config.lsa_components, min_df=config.min_df, random_state=config.random_state, ), ), ("norm", Normalizer()), ] ), ) ) if not branches: raise ValueError("Не выбран ни один блок признаков.") if len(branches) == 1: return branches[0][1] return FeatureUnion(branches) def make_classifier(name: str, **params: Any) -> Any: """Создаёт классификатор по короткому имени.""" if name == "dummy": strategy = params.pop("strategy", "most_frequent") return DummyClassifier(strategy=strategy) if name == "multinomial_nb": return MultinomialNB(alpha=params.pop("alpha", 1.0)) if name == "logreg": return LogisticRegression( C=params.pop("C", 1.0), solver="lbfgs", max_iter=5000, class_weight="balanced", ) if name == "linear_svc": return LinearSVC( C=params.pop("C", 1.0), class_weight="balanced", max_iter=20_000, dual="auto", ) if name == "random_forest": return RandomForestClassifier( n_estimators=params.pop("n_estimators", 400), max_depth=params.pop("max_depth", None), min_samples_leaf=params.pop("min_samples_leaf", 1), class_weight="balanced_subsample", n_jobs=-1, random_state=params.pop("random_state", 42), ) if name == "extra_trees": return ExtraTreesClassifier( n_estimators=params.pop("n_estimators", 400), max_depth=params.pop("max_depth", None), min_samples_leaf=params.pop("min_samples_leaf", 1), class_weight="balanced", n_jobs=-1, random_state=params.pop("random_state", 42), ) if name == "knn": return KNeighborsClassifier( n_neighbors=params.pop("n_neighbors", 5), metric=params.pop("metric", "cosine"), weights=params.pop("weights", "distance"), n_jobs=-1, ) raise ValueError(f"Неизвестный классификатор: {name}. Доступны: {MODEL_NAMES}") def build_pipeline( feature_config: FeatureConfig, model_name: str, **model_params: Any, ) -> Pipeline: """Собирает полный конвейер: признаки -> классификатор. Для MultinomialNB исключаются LSA-компоненты (они могут быть отрицательными) и используется MinMax-масштабирование, чтобы матрица оставалась неотрицательной. """ if model_name == "dummy": return Pipeline([("model", make_classifier(model_name, **model_params))]) if model_name == "multinomial_nb": config = FeatureConfig( groups=feature_config.groups, use_char_ngrams=feature_config.use_char_ngrams, use_pos_ngrams=feature_config.use_pos_ngrams, lsa_variants=(), lsa_components=feature_config.lsa_components, stylo_scaler="minmax", stylo_normalize=feature_config.stylo_normalize, include_stylo=feature_config.include_stylo, include_lsa=False, char_max_features=feature_config.char_max_features, pos_max_features=feature_config.pos_max_features, char_ngram_range=feature_config.char_ngram_range, pos_ngram_range=feature_config.pos_ngram_range, min_df=feature_config.min_df, random_state=feature_config.random_state, ) features = build_feature_union(config) return Pipeline([("features", features), ("model", make_classifier(model_name, **model_params))]) features = build_feature_union(feature_config) return Pipeline([("features", features), ("model", make_classifier(model_name, **model_params))]) def preset_config(name: str = "stylometry_pos_lsa") -> FeatureConfig: """Готовые наборы признаков для типовых экспериментов.""" all_groups = tuple(FEATURE_GROUPS) if name == "stylometry": return FeatureConfig( groups=all_groups, use_char_ngrams=False, use_pos_ngrams=False, lsa_variants=(), stylo_normalize=False, include_lsa=False, ) if name == "stylometry_pos": return FeatureConfig( groups=all_groups, use_char_ngrams=True, use_pos_ngrams=True, lsa_variants=(), include_lsa=False, ) if name in {"stylometry_lsa", "stylometry_pos_lsa"}: return FeatureConfig( groups=all_groups, use_char_ngrams=True, use_pos_ngrams=True, lsa_variants=("word", "pos"), include_lsa=True, ) if name == "all": return FeatureConfig( groups=all_groups, use_char_ngrams=True, use_pos_ngrams=True, lsa_variants=("word", "word2", "content", "fw", "char", "pos"), include_lsa=True, ) if name == "char": return FeatureConfig( groups=(), use_char_ngrams=True, use_pos_ngrams=False, lsa_variants=(), include_stylo=False, include_lsa=False, ) if name == "pos": return FeatureConfig( groups=(), use_char_ngrams=False, use_pos_ngrams=True, lsa_variants=(), include_stylo=False, include_lsa=False, ) if name == "fw": return FeatureConfig( groups=("function_words", "pronouns"), use_char_ngrams=False, use_pos_ngrams=False, lsa_variants=(), stylo_normalize=False, include_lsa=False, ) if name in { "lsa_word", "lsa_word2", "lsa_content", "lsa_fw", "lsa_char", "lsa_pos" }: variant = name.removeprefix("lsa_") return FeatureConfig( groups=(), use_char_ngrams=False, use_pos_ngrams=False, lsa_variants=(variant,), include_stylo=False, include_lsa=True, ) if name == "length": return FeatureConfig( groups=("length",), use_char_ngrams=False, use_pos_ngrams=False, lsa_variants=(), stylo_normalize=False, include_lsa=False, ) raise ValueError(f"Неизвестный набор признаков: {name}") PARAM_GRIDS: dict[str, dict[str, list[Any]]] = { "logreg": {"model__C": [0.1, 1.0, 10.0]}, "linear_svc": {"model__C": [0.1, 1.0, 10.0]}, "random_forest": { "model__max_depth": [None, 30], "model__min_samples_leaf": [1, 2], }, "extra_trees": { "model__max_depth": [None, 30], "model__min_samples_leaf": [1, 2], }, "multinomial_nb": {"model__alpha": [0.1, 1.0, 3.0]}, "knn": {"model__n_neighbors": [3, 5, 9]}, } def make_grid_search( pipeline: Pipeline, model_name: str, cv: int = 3, scoring: str = "f1_macro", n_jobs: int = -1, ) -> GridSearchCV: """GridSearchCV с сеткой, специфичной для модели.""" if model_name not in PARAM_GRIDS: raise ValueError(f"Для модели {model_name} нет сетки гиперпараметров.") return GridSearchCV( pipeline, PARAM_GRIDS[model_name], cv=cv, scoring=scoring, n_jobs=n_jobs, refit=True, return_train_score=False, )