Первичная версия: стилометрия реплик, LSA и протокол экспериментов
This commit is contained in:
365
lab1/stylometry/pipelines.py
Normal file
365
lab1/stylometry/pipelines.py
Normal file
@@ -0,0 +1,365 @@
|
||||
"""Сборка признаковых пространств и классификаторов."""
|
||||
|
||||
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,
|
||||
)
|
||||
Reference in New Issue
Block a user