Image

Учебник PyTorch для начинающих: создание модели множественной регрессии с нуля

Практикум PyTorch: создание трёхслойной нейронной сети для множественной регрессии

Делиться

657859fa156df4215a2e798c01155de7

Некоторое время до того, как LLM стали популярными, существовала почти видимая граница, разделяющая фреймворки машинного обучения и фреймворки глубокого обучения.

Доклад был сосредоточен на Scikit-Learn, XGBoost и аналогичных технологиях для машинного обучения, в то время как PyTorch и TensorFlow доминировали, когда речь заходила о глубоком обучении.

Однако после взрывного развития искусственного интеллекта я наблюдаю, как PyTorch доминирует на сцене гораздо больше, чем TensorFlow. Оба фреймворка действительно мощные и позволяют специалистам по данным решать различные задачи, в том числе и задачи обработки естественного языка, что вновь повышает популярность глубокого обучения.

В этой статье я не собираюсь говорить об НЛП, а вместо этого поработаю с задачей многомерной линейной регрессии, преследуя две цели:

  • Обучение созданию модели с использованием PyTorch
  • Обмен знаниями о линейной регрессии, которые не всегда можно найти в других учебниках.

Давайте начнем.

Подготовка данных

Хорошо, позвольте мне избавить вас от замысловатого определения линейной регрессии. Вы, вероятно, уже не раз встречали его в бесчисленных руководствах по всему интернету. Итак, достаточно сказать, что если у вас есть переменная Y, которую вы хотите предсказать, и другая переменная X, которая может объяснить вариацию Y с помощью прямой линии, то это, по сути, линейная регрессия.

Набор данных

Для этого упражнения давайте воспользуемся набором данных Abalone [1].

Нэш, У., Селлерс, Т., Талбот, С., Коуторн, А. и Форд, У. (1994). Abalone [Набор данных]. Репозиторий машинного обучения Калифорнийского университета в Ирвайне. https://doi.org/10.24432/C55C7W.

Согласно документации набора данных, возраст морского ушка определяется путём разрезания раковины по конусу, окрашивания её и подсчёта количества колец под микроскопом — утомительное и трудоёмкое занятие. Для прогнозирования возраста используются другие, более простые измерения.

Итак, давайте загрузим данные. Кроме того, мы закодируем переменную «Пол» методом One Hot Encode, поскольку она единственная категориальная.

# Загрузка данных из ucimlrepo import fetch_ucirepo import pandas as pd import numpy as np import matplotlib.pyplot as plt import seaborn as sns sns.set_style('darkgrid') from feature_engine.encoding import OneHotEncoder # выборка набора данных abalone = fetch_ucirepo(id=1) # данные (в виде кадров данных pandas) X = abalone.data.features y = abalone.data.targets # One Hot Encode Sex ohe = OneHotEncoder(variables=['Sex']) X = ohe.fit_transform(X) # Просмотр df = pd.concat([X,y], axis=1)

Вот набор данных.

7ad8decce3e1c17e1b132d06b17cc549

Итак, чтобы создать лучшую модель, давайте исследуем данные.

Изучение данных

Первые шаги, которые я люблю выполнять при исследовании набора данных:

1. Проверка распределения целевой переменной.

# Смотрим на нашу целевую переменную plt.hist(y) plt.title('Распределение колец [целевая переменная]');

На графике показано, что целевая переменная распределена не нормально. Это может повлиять на регрессию, но обычно может быть исправлено с помощью степенного преобразования, например, логарифмического или преобразования Бокса-Кокса.

9867d257e9e21aa3e5fdb28fa4e16237

2. Посмотрите статистическое описание.

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

# Статистическое описание df.describe()

931789d6c10d714392cd987c4fbceb90

Далее проверим корреляции.

# Смотрим на корреляции (df .drop(['Sex_M', 'Sex_I', 'Sex_F'],axis=1) .corr() .style .background_gradient(cmap='coolwarm') )

a50cb3518ca5aee4a2b70f3a7e619736

Объясняющие переменные имеют среднюю или сильную корреляцию с числом колец. Мы также видим некоторую коллинеарность между Whole_weight, Shucked_weight, Viscera_weight и Shell_weight. Длина и диаметр также коллинеарны. Мы можем попробовать удалить их позже.

sns.pairplot(df);

Когда мы строим диаграммы рассеяния пар и смотрим на взаимосвязь переменных с помощью колец, мы можем быстро выявить некоторые проблемы.

  • Нарушается предположение о гомоскедастичности. Это означает, что зависимость неоднородна с точки зрения дисперсии.
  • Обратите внимание, как графики принимают форму конуса, увеличивая дисперсию Y по мере увеличения значений X. При оценке значения колец для более высоких значений переменных X оценка будет не очень точной.
  • Переменная Height имеет по крайней мере два выброса, которые очень заметны, когда Height > 0,3.
f3c1b976dcad4ca0991f96ea3d23cb96

Удаление выбросов и преобразование целевой переменной в логарифмы приведёт к следующему графику пар. Он лучше, но всё ещё не решает проблему гомоскедастичности.

491384b04914d936ca6d1f1c50babeda

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

Переменная Диаметр имеет наиболее линейную зависимость, когда Пол=I, но это все.

# Создать FacetGrid с диаграммами рассеяния sns.lmplot(x=»Diameter», y=»Rings», hue=»Sex», col=»Sex», order=2, data=df);

6cd5fc4c8f5b54055d3fb7e48dc3ee25

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

# Создать FacetGrid с диаграммами рассеяния sns.lmplot(x=»Shell_weight», y=»Rings», hue=»Sex», col=»Sex», data=df);

cecd7e83f49455544ba67bcc86882819

Всё это показывает, что модель линейной регрессии будет очень сложной для этого набора данных и, вероятно, не сработает. Но мы всё равно хотим её реализовать.

Кстати, я не помню, чтобы я видел пост, где мы бы подробно разбирали, что пошло не так. Так что, разобравшись с этим, мы тоже можем извлечь ценные уроки.

Моделирование: использование Scikit-Learn

Давайте запустим модель sklearn и оценим ее с помощью среднеквадратической ошибки.

из sklearn.linear_model импорт LinearRegression из sklearn.metrics импорт root_mean_squared_error df2 = df.query('Высота < 0,3 и кольца > 2 ').copy() X = df2.drop(['Кольца'], ось=1) y = np.log(df2['Кольца']) lr = LinearRegression() lr.fit(X, y) predicts = lr.predict(X) df2['Предсказания'] = np.exp(предсказания) print(root_mean_squared_error(df2['Кольца'], df2['Предсказания'])) 2.2383762717104916

Если взглянуть на заголовок, то можно подтвердить, что модель испытывает трудности с оценками для более высоких значений (например, строки 0, 6, 7 и 9).

e9b06c3ca8b44b0b1b520bb5e2eba29a

Шаг назад: пробуем другие трансформации

Хорошо. Что же нам теперь делать?

Вероятно, стоит удалить ещё выбросы и повторить попытку. Давайте попробуем использовать неконтролируемый алгоритм для поиска ещё выбросов. Применим коэффициент локального выброса , отбросив 5% выбросов.

Мы также удалим мультиколлинеарность, исключив Whole_weight и Length.

from sklearn.neighbors import LocalOutlierFactor from sklearn.preprocessing import StandardScaler from sklearn.pipeline import Pipeline # выборка набора данных abalone = fetch_ucirepo(id=1) # данные (в виде фреймов данных pandas) X = abalone.data.features y = abalone.data.targets # One Hot Encode Sex ohe = OneHotEncoder(variables=['Sex']) X = ohe.fit_transform(X) # Отбрасывание всего веса и длины (мультиколинейность) X.drop(['Whole_weight', 'Length'], axis=1, inplace=True) # Представление df = pd.concat([X,y], axis=1) # Давайте создадим Pipeline для масштабирования данных и поиска выбросов с помощью классификатора KNN steps = [ ('scale', StandardScaler()), ('LOF', LocalOutlierFactor(contamination=0.05)) ] # Подгонка и прогнозирование выбросов = Pipeline(steps).fit_predict(X) # Добавление столбца df['outliers'] = выбросы # Моделирование df2 = df.query('Height < 0.3 and Rings > 2 and throws != -1').copy() X = df2.drop(['Rings', 'outliers'], axis=1) y = np.log(df2['Rings']) lr = LinearRegression() lr.fit(X, y) predicts = lr.predict(X) df2['Predictions'] = np.exp(predictions) print(root_mean_squared_error(df2['Rings'], df2['Predictions'])) 2.238174395913869

Тот же результат. Хм…

Хорошо. Мы можем продолжить экспериментировать с переменными и конструированием признаков, и мы начнём замечать улучшения, например, при добавлении квадратов высоты, диаметра и веса оболочки. В сочетании с обработкой выбросов это снизит среднеквадратичное отклонение до 2,196 .

# Переменные второго порядка X['Diameter_2'] = X['Diameter'] ** 2 X['Height_2'] = X['Height'] ** 2 X['Shell_2'] = X['Shell_weight'] ** 2

Конечно, справедливо отметить, что каждая переменная, добавляемая в модели линейной регрессии, влияет на коэффициент R² и иногда завышает результат, создавая ложное впечатление об улучшении модели, хотя это не так. В данном случае модель действительно улучшается, поскольку мы добавляем в неё нелинейные компоненты с переменными второго порядка. Мы можем доказать это, рассчитав скорректированный коэффициент R². Он изменился с 0,495 до 0,517.

# Скорректированный R² из sklearn.metrics import r2_score r2 = r2_score(df2['Кольца'], df2['Предсказания']) n= df2.shape[0] p = df2.shape[1] — 1 adj_r2 = 1 — (1 — r2) * (n — 1) / (n — p — 1) print(f'R²: {r2}') print(f'Скорректированный R²: {adj_r2}')

С другой стороны, возвращение Whole_weight и Length может немного улучшить результаты, но я бы не рекомендовал этого делать. В этом случае мы добавим мультиколлинеарность и завысим значимость коэффициентов некоторых переменных, что может привести к ошибкам оценки в будущем.

Моделирование: использование PyTorch

Итак. Теперь, когда у нас есть базовая модель, идея состоит в том, чтобы создать линейную модель с использованием глубокого обучения и попытаться превзойти среднеквадратичное отклонение (RMSE) 2,196.

Хорошо. Для начала, позвольте мне сразу заявить: модели глубокого обучения лучше работают с масштабированными данными. Однако, поскольку все наши переменные X имеют одинаковый масштаб, нам не нужно об этом беспокоиться. Итак, продолжим.

импортировать torch импорт torch.nn как nn импорт torch.optim как optim из torch.utils.data импорт DataLoader, TensorDataset

Нам необходимо подготовить данные для моделирования с помощью PyTorch. Здесь нам потребуется внести некоторые изменения, чтобы сделать данные приемлемыми для фреймворка PyTorch, поскольку он не поддерживает обычные фреймы данных Pandas.

  • Давайте используем тот же фрейм данных из нашей базовой модели.
  • Разделить X и Y
  • Преобразуем переменную Y в логарифм
  • Преобразуйте оба в массивы numpy, поскольку PyTorch не принимает фреймы данных.

df2 = df.query('Высота < 0,3 и Количество колец > 2 и выбросы != -1').copy() X = df2.drop(['Кольца', 'выбросы'], ось=1) y = np.log(df2[['Кольца']]) # X и Y в Numpy X = X.to_numpy() y = y.to_numpy()

Затем, используя TensorDataset, мы превращаем X и Y в объекты Tensor и выводим результат.

# Подготовка с помощью TensorData # TensorData помогает нам преобразовать набор данных в объект Tensor dataset = TensorDataset(torch.tensor(X).float(), torch.tensor(y).float()) input_sample, label_sample = dataset[0] print(f'** Входной образец: {input_sample}, n** Образец метки: {label_sample}') ** Входной образец: tensor([0.3650, 0.0950, 0.2245, 0.1010, 0.1500, 1.0000, 0.0000, 0.0000, 0.1332, 0.0090, 0.0225]), ** Образец метки: tensor([2.7081])

Затем, используя функцию DataLoader, мы можем создавать пакеты данных. Это означает, что нейронная сеть будет обрабатывать данные размером batch_size за раз.

# Далее используем DataLoader batch_size = 500 dataloader = DataLoader(dataset, batch_size=batch_size, shuffle=True)

Модели PyTorch лучше всего определить как классы.

  • Класс основан на nn.Module, который является базовым классом PyTorch для нейронных сетей.
  • Мы определяем слои модели, которые хотим использовать, в методе init .
    • super().__init__() гарантирует, что класс будет вести себя как объект-фонарик.
  • Метод прямого действия описывает, что происходит с входными данными при передаче их в модель .

Здесь мы пропускаем его через линейные слои, которые мы определили в методе init, и используем функции активации ReLU, чтобы добавить некоторую нелинейность в модель в прямом проходе.

# 2. Создание класса class AbaloneModel(nn.Module): def __init__(self): super().__init__() self.linear1 = nn.Linear(in_features=X.shape[1], out_features=128) self.linear2 = nn.Linear(128, 64) self.linear3 = nn.Linear(64, 32) self.linear4 = nn.Linear(32, 1) def forward(self, x): x = self.linear1(x) x = nn.functional.relu(x) x = self.linear2(x) x = nn.functional.relu(x) x = self.linear3(x) x = nn.functional.relu(x) x = self.linear4(x) return x # Создание экземпляра модели model = AbaloneModel()

Далее давайте впервые попробуем модель, используя скрипт, имитирующий случайный поиск.

  • Создать критерий ошибки для оценки модели
  • Создайте список для хранения данных из лучшей модели и задайте для best_loss высокое значение, чтобы оно заменялось лучшими показателями потерь во время итерации.
  • Установите диапазон скорости обучения. Мы будем использовать коэффициенты мощности от -2 до -4 (например, от 0,01 до 0,0001).
  • Установите диапазон импульса от 0,9 до 0,99.
  • Получить данные
  • Обнулите градиент, чтобы очистить расчеты градиента из предыдущих итераций.
  • Подогнать модель
  • Рассчитайте потери и зарегистрируйте показатели лучшей модели.
  • Вычислите веса и смещения с помощью обратного прохода.
  • Повторите N раз и выведите лучшую модель.

# Среднеквадратическая ошибка (MSE) является стандартной для критерия регрессии = nn.MSELoss() # Значения случайного поиска = [] best_loss = 999 для idx в диапазоне (1000): # Случайным образом выбирается фактор скорости обучения от 2 до 4 factor = np.random.uniform(2,5) lr = 10 ** -factor # Случайным образом выбирается импульс от 0,85 до 0,99 momentum = np.random.uniform(0.90, 0.99) # 1. Получить признак данных, target = dataset[:] # 2. Нулевые градиенты: очистить старые градиенты перед обратным проходом optimizer = optim.SGD(model.parameters(), lr=lr, momentum=momentum) optimizer.zero_grad() # 3. Прямой проход: вычислить прогноз y_pred = model(feature) # 4. Вычислить потери loss = criteria(y_pred, target) # 4.1 Зарегистрировать лучший убыток, если убыток < лучший_убыток: лучший_убыток = убыток лучший_lr = lr лучший_моментум = импульс лучший_idx = idx # 5. Обратный проход: вычислить градиент убытка относительно W и b' потерь.backward() # 6. Обновить параметры: скорректировать W и b, используя вычисленные градиенты optimizer.step() значения.append([idx, lr, импульс, убыток]) print(f'n: {idx},lr: {lr}, импульс: {моментум}, убыток: {убыток}') n: 999,lr: 0.004782946959508322, импульс: 0.9801209929050066, убыток: 0.06135804206132889

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

# — 3. Функция потерь и оптимизатор — # Среднеквадратическая ошибка (MSE) является стандартным критерием для регрессии = nn.MSELoss() # Стохастический градиентный спуск (SGD) с малой скоростью обучения (lr) оптимизатор = optim.SGD(model.parameters(), lr=0.004, momentum=0.98)

Затем мы повторно обучим эту модель, используя те же шаги, что и раньше, но на этот раз сохраняя ту же скорость обучения и импульс.

Подгонка модели PyTorch требует более длинного скрипта, чем обычный метод fit() из Scikit-Learn. Но это не так уж и важно. Структура всегда будет похожа на следующие шаги:

  1. Активируйте режим model.train()
  2. Создайте цикл с нужным количеством итераций. Каждая итерация называется эпохой.
  3. Обнулите градиенты из предыдущих проходов с помощью optimizer.zero_grad().
  4. Получите пакеты из загрузчика данных.
  5. Вычислите прогнозы с помощью model(X)
  6. Рассчитайте убыток, используя критерий (y_pred, target).
  7. Выполните обратный проход для вычисления весов и смещения: loss.backward()
  8. Обновите веса и смещение с помощью optimizer.step()

Мы будем обучать эту модель в течение 1000 эпох (итераций). Здесь мы добавляем только один шаг, чтобы получить в итоге наилучшую модель, поэтому мы используем модель с наименьшим значением потерь.

# 4. Обучение torch.manual_seed(42) NUM_EPOCHS = 1001 loss_history = [] best_loss = 999 # Перевод модели в режим обучения model.train() for epoch in range(NUM_EPOCHS): for data in dataloader: # 1. Получить признак данных, цель = данные # 2. Нулевые градиенты: очистить старые градиенты перед обратным проходом optimizer.zero_grad() # 3. Прямой проход: вычислить прогноз y_pred = model(feature) # 4. Вычислить потери loss = criteria(y_pred, target) loss_history.append(loss) # Получить лучшую модель, если loss < best_loss: best_loss = loss best_model_state = model.state_dict() # сохранить лучшую модель # 5. Обратный проход: вычислить градиент потерь относительно W и b' loss.backward() # 6. Обновить параметры: скорректировать W и b с помощью вычисленных градиентов optimizer.step() # Загрузите лучшую модель перед возвратом прогнозов model.load_state_dict(best_model_state) # Выведите статус каждые 50 эпох, если epoch % 200 == 0: print(epoch, loss.item()) print(f'Best Loss: {best_loss}') 0 0,061786893755197525 Лучший убыток: 0,06033024191856384 200 0,036817338317632675 Лучший убыток: 0,03243456035852432 400 0,03307393565773964 Лучший убыток: 0,03077109158039093 600 0,032522525638341904 Лучший убыток: 0,030613820999860764 800 0,03488151729106903 Лучший убыток: 0,029514113441109657 1000 0,0369877889752388 Лучший убыток: 0,029514113441109657

Отлично. Модель обучена. Теперь пора оценить.

Оценка

Давайте проверим, превзошла ли эта модель результаты обычной регрессии. Для этого я переведу модель в режим оценки с помощью model.eval(), чтобы PyTorch знал, что нужно изменить поведение модели, полученное в ходе обучения, и перейти в режим вывода. Например, это отключит нормализацию слоёв и исключения элементов.

# Получить признаки features, target = dataset[:] # Получить прогнозы model.eval() с torch.no_grad(): predicts = model(features) # Добавить в фрейм данных df2['Predictions'] = np.exp(predictions.detach().numpy()) # RMSE print(root_mean_squared_error(df2['Rings'], df2['Predictions'])) 2.1108551025390625

Улучшение было скромным, около 4% .

Давайте рассмотрим некоторые прогнозы каждой модели.

a6b7fd6456c649d63cc58b8c509e7cc3

Обе модели дают очень похожие результаты. С ростом числа колец они становятся всё более затруднительными. Это связано с конусообразной формой целевой переменной.

Если задуматься на мгновение:

  • По мере увеличения количества колец увеличивается дисперсия объясняющей переменной.
  • Морское ушко с 15 кольцами будет иметь гораздо более широкий диапазон значений, чем такое же с 4 кольцами.
  • Это сбивает модель с толку, поскольку ей приходится рисовать одну линию посередине данных, которая не столь линейна.

Прежде чем уйти

В ходе этого проекта мы многому научились:

  • Как исследовать данные.
  • Как проверить, будет ли линейная модель хорошим вариантом.
  • Как создать модель PyTorch для многомерной линейной регрессии.

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

Мы попытались улучшить результат с помощью глубокого обучения, но всей этой мощи оказалось недостаточно, чтобы значительно снизить погрешность. Я бы, пожалуй, выбрал модель Scikit-Learn, поскольку она проще и понятнее.

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

Если вам понравился этот контент, найдите меня на моем сайте.

https://gustavorsantos.me

Репозиторий GitHub

Код для этого упражнения.

https://github.com/gurezende/Linear-Regression-PyTorch

Ссылки

[1. Набор данных Abalone – Репозиторий UCI, лицензия CC BY 4.0.] https://archive.ics.uci.edu/dataset/1/abalone

[2. Режим оценки] https://stackoverflow.com/questions/60018578/what-does-model-eval-do-in-pytorch

https://docs.pytorch.org/docs/stable/generated/torch.nn.Module.html#torch.nn.Module.eval

[3. Документация PyTorch] https://docs.pytorch.org/docs/stable/nn.html

[4. Блокнот Kaggle] https://www.kaggle.com/code/samlakhmani/s4e4-deeplearning-with-oof-strategy

[5. Репозиторий GitHub] https://github.com/gurezende/Linear-Regression-PyTorch

Источник: towardsdatascience.com

✅ Найденные теги: новости, Учебник

ОСТАВЬТЕ СВОЙ КОММЕНТАРИЙ

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Каталог бесплатных опенсорс-решений, которые можно развернуть локально и забыть о подписках

галерея

Фото сгенерированных лиц: исследование показывает, что люди не могут отличить настоящие лица от сгенерированных
Нейросети построили капитализм за трое суток: 100 агентов Claude заперли…
Скетч: цифровой осьминог и виртуальный мир внутри компьютера с человечком.
Сцена с жестами пальцами, где один жест символизирует "VPN", а другой "KHP".
‼️Paramount купила Warner Bros. Discovery — сумма сделки составила безумные…
Скриншот репозитория GitHub "Claude Scientific Skills" AI для научных исследований.
Структура эффективного запроса Claude с элементами задачи, контекста и референса.
Эскиз и готовая веб-страница платформы для AI-дизайна в современном темном режиме.
ideipro logotyp
Image Not Found
Звёздное небо с галактиками и туманностями, космос, Вселенная, астрофотография.

Система оповещения обсерватории Рубина отправила 800 000 сигналов в первую ночь наблюдений.

Астрономы будут получать оповещения о небесных явлениях в течение нескольких минут после их обнаружения. Теренс О'Брайен, редактор раздела «Выходные». Публикации этого автора будут добавляться в вашу ежедневную рассылку по электронной почте и в ленту новостей на главной…

Мар 2, 2026
Женщина с длинными тёмными волосами в синем свете, нейтральный фон.

Расследование в отношении 61-фунтовой машины, которая «пожирает» пластик и выплевывает кирпичи.

Обзор компактного пресса для мягкого пластика Clear Drop — и что будет дальше. Шон Холлистер, старший редактор Публикации этого автора будут добавляться в вашу ежедневную рассылку по электронной почте и в ленту новостей на главной странице вашего…

Мар 2, 2026
Черный углеродное волокно с текстурой плетения, отражающий свет.

Материал будущего: как работает «бессмертный» композит

Учёные из Университета штата Северная Каролина представили композит нового поколения, способный самостоятельно восстанавливаться после серьёзных повреждений.  Речь идёт о модифицированном армированном волокном полимере (FRP), который не просто сохраняет прочность при малом весе, но и способен «залечивать» внутренние…

Мар 2, 2026
Круглый экран с изображением замка и горы, рядом электронная плата.

Круглый дисплей Waveshare для креативных проектов

Круглый 7-дюймовый сенсорный дисплей от Waveshare создан для разработчиков и дизайнеров, которым нужен нестандартный экран.  Это IPS-панель с разрешением 1 080×1 080 пикселей, поддержкой 10-точечного ёмкостного сенсора, оптической склейкой и защитным закалённым стеклом, выполненная в круглом форм-факторе.…

Мар 2, 2026

Впишите свой почтовый адрес и мы будем присылать вам на почту самые свежие новости в числе самых первых