Логистическая регрессия в Python: пример на scikit-learn с разбором метрик

Пошаговый пример логистической регрессии в Python на scikit-learn 1.9.1: от разделения выборки до predict_proba и метрик по классам.

Разбираем отношение шансов, выбор порога, параметры C и l1_ratio вместо устаревшего penalty, веса классов и пять ошибок, из-за которых модель не сходится или показывает на тесте завышенный результат.

Статью написал:
Ваня Буявец, продюсер, основатель Checkroi
Ваня Буявец
Основатель Checkroi, продюсер, эксперт в выборе онлайн-курсов
Все 3233 статьи автора Подписаться на Телеграм-канал
Одобрено экспертом:
Наташа Буявец, основатель Checkroi, эксперт по онлайн-курсам
Наташа Буявец
Основательница Checkroi, продюсер Youtube-каналов, эксперт по онлайн-курсам
Все 3887 экспертных мнений Подписаться на Телеграм-канал
Обложка: Логистическая регрессия в Python: пример на scikit-learn с разбором метрик

Логистическая регрессия в Python собирается из трёх объектов scikit-learn: StandardScaler приводит признаки к одному масштабу, LogisticRegression учится на обучающей части выборки, а метод predict_proba возвращает вероятность класса для каждого нового объекта. Весь рабочий код занимает около десяти строк.

Ниже пример на встроенном датасете с реальным выводом интерпретатора: обучение, метрики, коэффициенты и отношение шансов, порог классификации, регуляризация и ошибки, на которых новички теряют больше всего времени. Если с регрессией вы пока не встречались, начните с разбора линейной регрессии и метода наименьших квадратов: логистическая модель устроена похоже.

CheckroiCheckroiПодборка курсов по машинному обучению148 курсов • 29 школСравните цены, школы, программу и найдите выгодные предложения по обучениюСравнить→

Как работает логистическая регрессия: сигмоида и логиты

Рой разбирает график сигмоиды на ноутбуке

Логистическая регрессия решает задачу классификации: письмо спам или нет, клиент уйдёт или останется, опухоль злокачественная или доброкачественная. Модель отвечает вероятностью от 0 до 1, а класс получается сравнением этой вероятности с порогом.

Внутри всё начинается как в обычной регрессии: модель складывает признаки с весами и получает число z.

z = b0 + b1·x1 + b2·x2 + … + bn·xn
p = 1 / (1 + e^(−z))

Число z называют логитом. Он может быть любым, от минус до плюс бесконечности. Сигмоида (логистическая функция) сжимает его в вероятность p. Обратная операция выглядит так: логит равен ln(p / (1 − p)), то есть логарифму шансов.

Вот как сигмоида переводит разные значения z в вероятность:

import numpy as np

def sigmoid(z):
    return 1 / (1 + np.exp(-z))

for z in (-4, -1, 0, 1, 4):
    print(z, round(sigmoid(z), 3))
-4 0.018
-1 0.269
0 0.5
1 0.731
4 0.982

При z = 0 вероятность ровно 0,5. Чем дальше логит от нуля, тем увереннее модель.

Чем логистическая регрессия отличается от линейной

Что сравниваем Линейная регрессия Логистическая регрессия
Задача Предсказать число: цену, спрос, время Предсказать класс и его вероятность
Выход модели Любое число Вероятность от 0 до 1
Как подбираются веса Метод наименьших квадратов Максимум правдоподобия, функция потерь log loss
Класс в scikit-learn LinearRegression LogisticRegression
Главная метрика R², MAE, RMSE ROC AUC, precision, recall, log loss

Название сбивает с толку: «регрессия» здесь означает регрессию для логита, а итоговая задача остаётся классификацией. Где логистическая модель стоит среди деревьев, бустинга и нейросетей, разобрано в обзоре моделей машинного обучения.

Одна деталь из документации scikit-learn важна с первой строки кода: класс LogisticRegression по умолчанию применяет регуляризацию. В справке сказано прямо: «regularization is applied by default». К этому вернёмся в разделе про параметр C.

Логистическая регрессия на Python в scikit-learn: пошагово

Код ниже запускали на Python 3.14.7, scikit-learn 1.9.1, pandas 3.1.1 и NumPy 2.5.4. Удобнее всего проходить его в Jupyter Notebook: каждый шаг в отдельной ячейке.

Шаг 1: установить библиотеки

pip install scikit-learn pandas

Лучше ставить пакеты в виртуальное окружение, чтобы версии не конфликтовали с другими проектами.

Шаг 2: загрузить данные и разделить выборку

Возьмём датасет breast cancer, который входит в scikit-learn и скачивается вместе с библиотекой. В нём 569 опухолей, 30 числовых признаков по снимкам клеток и два класса. Что вообще считается датасетом и как его собрать под свою задачу, рассказали отдельно.

from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split

data = load_breast_cancer(as_frame=True)
X, y = data.data, data.target
print(X.shape)
print(y.value_counts())

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.25, stratify=y, random_state=42
)
print(len(X_train), len(X_test))
(569, 30)
target
1    357
0    212
Name: count, dtype: int64
426 143

Обратите внимание на кодировку классов: в этом датасете 0 означает злокачественную опухоль (malignant), а 1 доброкачественную (benign). Модель будет считать вероятность класса 1, то есть доброкачественной.

Параметр stratify=y сохраняет долю классов в обеих частях. Без него в тестовую выборку на 143 объекта может случайно попасть заметно больше или меньше злокачественных случаев.

Шаг 3: масштабировать признаки и обучить модель

from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression

model = make_pipeline(StandardScaler(), LogisticRegression())
model.fit(X_train, y_train)

print(model.predict(X_test[:5]))
print(model.predict_proba(X_test[:5]).round(3))
print(round(model.score(X_test, y_test), 3))
[1 0 1 1 0]
[[0.031 0.969]
 [1.    0.   ]
 [0.441 0.559]
 [0.061 0.939]
 [0.825 0.175]]
0.986

make_pipeline склеивает масштабирование и модель в один объект. Среднее и разброс признаков StandardScaler запоминает только по обучающей части, а к тестовой применяет те же числа. Почему признаки вообще нужно приводить к одному масштабу, объясняет статья про нормализацию данных.

Шаг 4: прочитать вероятности

В выводе predict_proba два столбца, по одному на класс, в порядке model.classes_, здесь это [0 1]. Третья опухоль получила 0,559 для класса 1: модель отнесла её к доброкачественным, но почти наугад.

Чтобы взять только вероятность класса 1, пишут predict_proba(X_test)[:, 1]. Это двумерный срез: все строки, второй столбец.

score показывает долю верных ответов на тесте: 0,986, или 141 из 143. Одной этой цифры для решения мало, поэтому дальше смотрим метрики по классам.

Канал основателя Checkroi Вани Буявца3 800 человек читают мой Телеграм-канал про нейросетиСобрал промпты для Claude Code и ChatGPT, разборы ИИ-инструментов и лайфхаки по продвижению бизнеса в одном местеПрисоединиться

Как оценить качество модели

Accuracy прячет главное: на каком классе модель ошибается. Матрица ошибок и отчёт по классам показывают это сразу.

from sklearn.metrics import confusion_matrix, classification_report, roc_auc_score

y_pred = model.predict(X_test)
proba = model.predict_proba(X_test)[:, 1]
print(confusion_matrix(y_test, y_pred))
print(classification_report(y_test, y_pred, target_names=data.target_names, digits=3))
print("ROC AUC:", round(roc_auc_score(y_test, proba), 4))
[[52  1]
 [ 1 89]]
              precision    recall  f1-score   support

   malignant      0.981     0.981     0.981        53
      benign      0.989     0.989     0.989        90

    accuracy                          0.986       143
   macro avg      0.985     0.985     0.985       143
weighted avg      0.986     0.986     0.986       143

ROC AUC: 0.9977

В первой строке матрицы 53 злокачественных опухоли: 52 модель нашла, одну пропустила. Во второй строке 90 доброкачественных, одну из них модель приняла за злокачественную.

Метрика Что показывает Когда смотреть
Precision Какая доля объектов, отнесённых к классу, действительно из него Ложная тревога дорогая: блокировка карты, лишний звонок клиенту
Recall Какую долю объектов класса модель нашла Пропуск дорогой: болезнь, мошенничество, брак
F1 Гармоническое среднее precision и recall Нужна одна цифра для сравнения моделей
ROC AUC Насколько хорошо модель ранжирует объекты по вероятности, без привязки к порогу Сравнение моделей до выбора порога

Кросс-валидация вместо одного разбиения

Одна тестовая выборка на 143 объекта может оказаться удачной или неудачной. cross_val_score пять раз обучает модель на разных частях обучающих данных и проверяет на оставшейся:

from sklearn.model_selection import cross_val_score

scores = cross_val_score(model, X_train, y_train, cv=5, scoring="roc_auc")
print(scores.round(3), round(scores.mean(), 3))
[0.993 0.999 0.979 0.997 0.998] 0.993

Разброс от 0,979 до 0,999 показывает, что результат почти не зависит от того, как разбили данные.

Калибровочная кривая

Если вероятности пойдут в расчёт денег или риска, проверьте калибровку: у хорошо откалиброванной модели среди объектов с прогнозом около 0,8 примерно 80 % действительно относятся к классу 1. Кривую строит calibration_curve, а строки вывода удобно собрать через f-строки:

import numpy as np
from sklearn.calibration import calibration_curve

frac_pos, mean_pred = calibration_curve(y_test, proba, n_bins=5)
counts = np.histogram(proba, bins=5, range=(0, 1))[0]
print("объектов в корзинах:", counts)
for p, f in zip(mean_pred, frac_pos):
    print(f"предсказано {p:.2f} → на деле {f:.2f}")
объектов в корзинах: [49  4  4  1 85]
предсказано 0.02 → на деле 0.00
предсказано 0.33 → на деле 0.25
предсказано 0.58 → на деле 1.00
предсказано 0.70 → на деле 1.00
предсказано 0.98 → на деле 0.99

Крайние корзины, где 49 и 85 объектов, совпадают с прогнозом. В средних по одному–четыре объекта, и строка «0.58 → 1.00» посчитана всего по четырём опухолям. Калибровку проверяют на сотнях объектов в каждой корзине, на маленькой выборке кривая скачет.

Как читать коэффициенты и отношение шансов

Коэффициенты лежат в атрибуте coef_ последнего шага пайплайна. Экспонента коэффициента даёт отношение шансов (odds ratio): во сколько раз меняются шансы класса 1, если признак вырастет на единицу.

import numpy as np
import pandas as pd

clf = model[-1]
coefs = pd.Series(clf.coef_[0], index=X.columns)
top = coefs.reindex(coefs.abs().sort_values(ascending=False).index)[:5]
print(pd.DataFrame({"coef": top.round(3), "odds_ratio": np.exp(top).round(3)}))
print("intercept:", clf.intercept_.round(3))
                       coef  odds_ratio
worst texture        -1.250       0.286
radius error         -1.070       0.343
worst symmetry       -0.957       0.384
worst concave points -0.942       0.390
area error           -0.941       0.390
intercept: [0.281]

Признаки прошли через StandardScaler, поэтому «единица» здесь равна одному стандартному отклонению. Рост worst texture на одно отклонение умножает шансы доброкачественной опухоли на 0,286, то есть снижает их примерно в 3,5 раза. Минус у всех пяти коэффициентов логичен: модель считает вероятность класса 1, а высокие значения этих признаков чаще встречаются у злокачественных опухолей.

Сравнивать коэффициенты по модулю можно только после масштабирования. На сырых данных признак в тысячах (площадь) и признак в долях (гладкость) получат несопоставимые веса.

P-значения и доверительные интервалы через statsmodels

scikit-learn не считает p-значения. Если нужен статистический отчёт, с которым работают в эконометрике и медицинской статистике, используйте Logit из statsmodels (ставится командой pip install statsmodels, мы запускали версию 0.15.0). Свободный член библиотека сама не добавляет, его подключает sm.add_constant:

import numpy as np
import statsmodels.api as sm

cols = ["mean radius", "mean texture"]
X_sm = sm.add_constant(X_train[cols])
result = sm.Logit(y_train, X_sm).fit(disp=0)
print(result.summary2().tables[1].round(3))
print(np.exp(result.params[cols]).round(3))
               Coef.  Std.Err.      z  P>|z|  [0.025  0.975]
const         19.317     2.004  9.638    0.0  15.389  23.245
mean radius   -1.060     0.118 -8.993    0.0  -1.290  -0.829
mean texture  -0.193     0.040 -4.796    0.0  -0.271  -0.114
mean radius     0.347
mean texture    0.825
dtype: float64

Здесь признаки не масштабированы, и коэффициент читается в исходных единицах датасета: каждая единица mean radius умножает шансы доброкачественной опухоли на 0,347. Ноль в столбце P>|z| получился из-за округления до трёх знаков, само значение меньше 0,0005.

Логика чтения p-значения та же, что в A/B-тестах: оно показывает, насколько вероятно получить такой или ещё более далёкий от нуля коэффициент, если признак на самом деле ни на что не влияет.

statsmodels по умолчанию обучает модель без регуляризации, а scikit-learn с ней. Поэтому коэффициенты двух библиотек на одних данных совпадать не обязаны.

Настройка модели: порог, регуляризация и веса классов

Рой доволен подобранным порогом модели

Порог классификации

predict относит объект к классу 1, если вероятность больше 0,5. Для медицины и антифрода такой порог часто плохой: пропуск дороже ложной тревоги. Порог можно сдвинуть вручную через predict_proba. Посчитаем, как меняется поиск злокачественных опухолей:

from sklearn.metrics import recall_score, precision_score

p_malignant = model.predict_proba(X_test)[:, 0]
y_true = (y_test == 0)
for t in (0.5, 0.3, 0.1):
    y_hat = p_malignant >= t
    print(t, "recall:", round(recall_score(y_true, y_hat), 3),
          "precision:", round(precision_score(y_true, y_hat), 3))
0.5 recall: 0.981 precision: 0.981
0.3 recall: 0.981 precision: 0.897
0.1 recall: 1.0 precision: 0.828

При пороге 0,1 модель находит все 53 злокачественные опухоли, но поднимает 11 ложных тревог вместо одной. Порог 0,3 здесь ничего не даёт: пропуск остаётся тот же, а ложных тревог становится шесть.

Решение: выбирайте порог по цене ошибок в вашей задаче на отдельной валидационной выборке. Тестовую оставьте для финальной проверки.

Регуляризация: параметр C и l1_ratio

Параметр C задаёт обратную силу регуляризации. Чем меньше C, тем сильнее штраф за большие коэффициенты. Значение C=np.inf выключает штраф полностью.

В scikit-learn 1.8 параметр penalty объявили устаревшим, его уберут в версии 1.10. Вместо penalty="l1" теперь пишут l1_ratio=1, вместо penalty="l2" пишут l1_ratio=0 (это значение по умолчанию), промежуточные значения дают elastic net. Старый код пока работает, но на 1.9.1 выдаёт FutureWarning:

FutureWarning: 'penalty' was deprecated in version 1.8 and will be removed in 1.10.

L1-штраф обнуляет часть коэффициентов, то есть сам отбирает признаки. Проверим на разных C:

for C in (0.01, 0.1, 1, 10):
    m = make_pipeline(StandardScaler(),
                      LogisticRegression(C=C, l1_ratio=1, solver="liblinear", random_state=0))
    m.fit(X_train, y_train)
    nonzero = np.count_nonzero(m[-1].coef_)
    print(C, "признаков в модели:", nonzero, "точность:", round(m.score(X_test, y_test), 3))
0.01 признаков в модели: 4 точность: 0.909
0.1 признаков в модели: 7 точность: 0.972
1 признаков в модели: 16 точность: 0.993
10 признаков в модели: 22 точность: 0.958

При C=0.01 из 30 признаков остаются 4, и модель недообучается. При C=10 в модели 22 признака, а точность на тесте падает. Подбирать C лучше через GridSearchCV или LogisticRegressionCV на кросс-валидации.

random_state=0 в коде нужен из-за liblinear: этот решатель перемешивает данные, и без фиксированного зерна у нас при повторных запусках при C=0.1 в модели оставалось то 8, то 7 признаков.

Не каждый решатель умеет каждый штраф. По руководству scikit-learn:

solver Какой l1_ratio поддерживает Многоклассовая задача
lbfgs (по умолчанию) только 0 (L2) да
liblinear 0 или 1 нет
newton-cg, newton-cholesky, sag только 0 (L2) да
saga от 0 до 1, включая elastic net да

Несбалансированные классы и class_weight

Если класс 1 встречается в 5 % строк, модель может почти всегда отвечать «0» и получать высокую accuracy. Сгенерируем такую выборку и сравним модель с весами классов и без них:

from sklearn.datasets import make_classification
from sklearn.metrics import accuracy_score

X, y = make_classification(n_samples=5000, n_features=10, weights=[0.95],
                           class_sep=0.8, random_state=1)
print("доля класса 1:", round(y.mean(), 3))
X_tr, X_te, y_tr, y_te = train_test_split(X, y, stratify=y, random_state=1)

for cw in (None, "balanced"):
    m = LogisticRegression(class_weight=cw).fit(X_tr, y_tr)
    p = m.predict(X_te)
    print(cw, "accuracy:", round(accuracy_score(y_te, p), 3),
          "recall класса 1:", round(recall_score(y_te, p), 3))
доля класса 1: 0.054
None accuracy: 0.956 recall класса 1: 0.25
balanced accuracy: 0.813 recall класса 1: 0.794

Без весов модель находит только четверть редкого класса. С class_weight="balanced" находит 79 %, а accuracy падает до 0,813, потому что ложных срабатываний становится больше. Веса scikit-learn считает по формуле n_samples / (n_classes * np.bincount(y)): редкий класс получает вес во столько раз больше, во сколько раз он реже.

Многоклассовая логистическая регрессия

Для трёх и более классов ничего переключать не нужно. Все решатели, кроме liblinear, сами обучают мультиномиальную модель, softmax-обобщение сигмоиды. Параметр multi_class из старых туториалов в 1.9.1 уже не существует.

from sklearn.datasets import load_iris

X, y = load_iris(return_X_y=True)
X_tr, X_te, y_tr, y_te = train_test_split(X, y, stratify=y, random_state=0)
m = LogisticRegression(max_iter=1000).fit(X_tr, y_tr)
print(m.coef_.shape)
print(m.predict_proba(X_te[:2]).round(3))
print(round(m.score(X_te, y_te), 3))
(3, 4)
[[0.968 0.032 0.   ]
 [0.966 0.034 0.   ]]
1.0

У модели теперь по строке коэффициентов на каждый из трёх сортов ириса, а вероятности в строке predict_proba в сумме дают единицу. Точность 1,0 объясняется учебным датасетом: виды хорошо разделяются по размерам лепестков, на реальных данных такого не ждите.

Ваня БуявецКанал основателя Checkroi Вани БуявцаЗабирайте промпты и обучение по нейросетям в моём Телеграм-каналеБольше 3 800 человек уже применяют Claude Code, ChatGPT и другие нейросети в работе, учёбе, бизнесе и жизниПерейти в канал

Частые ошибки и что с ними делать

Ноутбук с кодом, блокнот с формулой сигмоиды и кружка на столе

ConvergenceWarning: lbfgs failed to converge

Модель обучили на сырых признаках, без масштабирования:

clf = LogisticRegression()
clf.fit(X_train, y_train)
print(clf.n_iter_, round(clf.score(X_test, y_test), 3))
ConvergenceWarning: lbfgs failed to converge after 100 iteration(s) (status=1):
STOP: TOTAL NO. OF ITERATIONS REACHED LIMIT
[100] 0.958

Вывод сокращён: после строки STOP scikit-learn ещё советует поднять max_iter или масштабировать данные. Решатель упёрся в лимит max_iter=100, и точность упала с 0,986 до 0,958. Решение: добавьте StandardScaler в пайплайн. Если просто поднять max_iter до 10 000 и не масштабировать признаки, предупреждение уйдёт, а причина останется.

ValueError при L1-штрафе с решателем lbfgs

Ошибка появляется, если попросить L1-штраф у решателя по умолчанию: LogisticRegression(l1_ratio=1) или старое penalty="l1".

ValueError: Solver lbfgs supports only 'l2' or None penalties, got l1 penalty.

Решение: укажите solver="liblinear" или solver="saga", совместимость решателей и штрафов есть в таблице выше.

Масштабирование до разделения выборки

  • Симптом: на тесте метрики отличные, на новых данных хуже.
  • Причина: StandardScaler().fit_transform(X) вызвали на всех данных до train_test_split, и среднее тестовой части просочилось в обучение.
  • Решение: поместите масштабирование внутрь make_pipeline, тогда и cross_val_score переобучает его на каждом фолде.

Перепутанный положительный класс

predict_proba(X)[:, 1] всегда возвращает вероятность класса, который стоит вторым в classes_. В breast cancer это доброкачественная опухоль, хотя по смыслу задачи «положительным» хочется считать злокачественную. Перед расчётом recall и выбором порога проверьте model.classes_ и data.target_names.

Accuracy на несбалансированных данных

Accuracy 0,956 в примере выше получилась у модели, которая пропускает три четверти редкого класса. Смотрите recall и precision по каждому классу, ROC AUC или PR AUC.

Ещё часто путают корреляция признака с целевой переменной и его коэффициент в модели, а это разные вещи. При сильно связанных признаках коэффициенты делят вклад между собой, и знак отдельного веса может удивить.

Где освоить машинное обучение на Python системно

Логистическую регрессию обычно изучают в первом модуле курсов по машинному обучению, сразу после линейной. Дальше идут деревья решений, бустинг, работа с признаками и вывод модели в продакшн. Подготовку данных под модель удобно отрабатывать на pandas, а путь в профессию с нуля мы разобрали в статье как стать Data Scientist.

Ниже курсы, где машинное обучение разбирают на Python с проектами и проверкой кода. Больше программ — в каталоге курсов по Python.

КурсШколаСтоимость со скидкойВ рассрочкуДлитель­ностьОбзор курса от Checkroi
Machine Learning с нуля до Junior
Перейти на сайт курса
SkillboxSkillbox123 543 ₽4384 ₽/мес.9 месяцевОбзор курса
Бакалавриат «Data Science & Machine Learning»
Перейти на сайт курса
SkillboxSkillbox75 000 ₽ - 48 месяцевОбзор курса
Машинное обучение
Перейти на сайт курса
НетологияНетология43 600 ₽2598 ₽/мес.10 месяцевОбзор курса
Machine Learning. Продвинутый уровень
Перейти на сайт курса
OTUSOTUS65 000 ₽8000 ₽/мес.5 месяцевОбзор курса
Machine Learning Engineer с нуля
Перейти на сайт курса
SkillboxSkillbox69 000 ₽ - 9 месяцевОбзор курса

Больше программ — в полном каталоге курсов по машинному обучению

Часто задаваемые вопросы

Что такое логистическая регрессия простыми словами?

Это модель классификации. Она складывает признаки с весами, переводит сумму в вероятность от 0 до 1 через сигмоиду и сравнивает её с порогом, по умолчанию 0,5.

Чем логистическая регрессия отличается от линейной?

Линейная регрессия предсказывает число, логистическая предсказывает вероятность класса. Веса линейной подбирают методом наименьших квадратов, логистической подбирают по максимуму правдоподобия. Подробно линейная модель разобрана в статье про линейную регрессию.

Что возвращает predict_proba в sklearn?

Массив вероятностей: строка на каждый объект, столбец на каждый класс в порядке model.classes_. В бинарной задаче вероятность класса 1 берут срезом predict_proba(X)[:, 1].

Как исправить ошибку «Solver lbfgs supports only… got l1 penalty»?

Решатель lbfgs не умеет L1-штраф. Укажите решатель liblinear или saga и задайте штраф через l1_ratio=1: параметр penalty устарел в scikit-learn 1.8 и будет удалён в версии 1.10.

Что делает class_weight со значением balanced?

Увеличивает вес редкого класса обратно пропорционально его частоте по формуле n_samples / (n_classes × np.bincount(y)). В нашем примере, где класс 1 составляет 5,4 % строк, recall редкого класса вырос с 25 до 79 %, а accuracy упала с 0,956 до 0,813.

Что такое логит в логистической регрессии?

Логит равен логарифму шансов, ln(p / (1 − p)). Внутри модели это сумма признаков с весами. Логит 0 соответствует вероятности 0,5, а отрицательный логит означает вероятность меньше 0,5.

Читайте Checkroi первым в Google
Добавьте Checkroi в избранные источники — и наши разборы курсов и обзоры школ будут показываться выше в вашей выдаче Google.
Оставить комментарий
0 комментариев
Форма комментария

Оставьте комментарий

Напишите, что думаете. Нам важно ваше мнение!