При анализе данных и построении моделей машинного обучения часто возникает необходимость аппроксимировать сложные функции. PyTorch предоставляет удобные инструменты для создания и обучения нейронных сетей, которые могут быть эффективно использованы для этой цели. В этом посте мы рассмотрим простой пример аппроксимации функции с использованием PyTorch.
Шаг 1: Подготовка данных
На этом шаге мы будем генерировать данные, поэтому можно без потери понимания дальнейших рассуждений перейти к следующему шагу, если вам не интересно почитать про диффуры.
Давайте начнем с простого примера из теории управления и решим его сначала аналитически, а затем с использованием PyTorch. Рассмотрим функцию
Это линейное неоднородное дифференциальное уравнение, гдепредставляет собой выход системы, - входной сигнал, а и обозначают, соответственно, первую и вторую производные по времени .
Тогда характеристический полином будет выглядеть
После нахождения корней характеристического уравнения мы можем записать общее решение дифференциального уравнения в виде комбинации экспоненциальных функций. Поскольку корни комплексные, мы можем использовать формулу Эйлера, чтобы выразить решение в терминах косинусов и синусов
где и - произвольные постоянные, которые определяются начальными условиями задачи. Давайте выберем их в нашей задаче и . Тогда можно полностью определить решение нашей задачи
Давайте промоделируем отклик системы для начальных условий помощью библиотеки control
init_state = [1, 2]
dt = 0.001
time = np.linspace(0, 10, int(time/dt))
# create transfer function
numerator = [1]
denominator = [1, 1, 1]
system = ctrl.tf(numerator, denominator)
# get respomse from the TF
response = ctrl.initial_response(system, time, init_state).outputs
plt.plot(time, response, label="Impulse response of the system")
Именно time
, response
мы позже будем использовать как обучающий датасет. Также заодно изобразим аналитическое решение задачи.
class Analytic:
def __init__(self, alpha, beta, c_1, c_2):
self.alpha = alpha
self.beta = beta
self.c_1 = c_1
self.c_2 = c_2
def calculate(self, t: float) -> float:
return self.c_1 * np.exp(self.alpha * t) * np.sin(self.beta * t) \
+ self.c_2 * np.exp(self.alpha * t) * np.cos(self.beta * t)
def __call__(self, t: list[float]) -> list[float]:
return np.vectorize(self.calculate)(t)
analytic = Analytic(
alpha = -0.5,
beta = np.sqrt(3)/2,
c_1 = 4/np.sqrt(3),
c_2 = 2
)
plt.plot(time, analytic(time), linestyle="--",
label="Analityc solution")
Шаг 2: Определение модели
Теперь надо определиться с архитектурой модели. Для нас это будет обычной функцией вида
где
α: обучаемый параметр, представляющий собой коэффициент экспоненциального роста.
β: Фиксированное значение (установлено на ): представляющее собой частоту осцилляции. Позже расскажу почему фиксированное.
и : Обучаемые параметры, представляющие коэффициенты для синусоидальных и косинусоидальных членов соответственно.
Теперь давайте напишем нашу модель с использованием PyTorch. Ниже приведен пример кода для создания модели:
class Model(nn.Module):
"""Our class that will be trained"""
def __init__(self):
super().__init__()
self.alpha = nn.Parameter(torch.zeros(1))
self.beta = np.sqrt(3)/2
self.c_1 = nn.Parameter(torch.zeros(1))
self.c_2 = nn.Parameter(torch.zeros(1))
def forward(self, t):
return self.c_1 * torch.exp(self.alpha * t) * torch.sin(self.beta * t) \
+ self.c_2 * torch.exp(self.alpha * t) * torch.cos(self.beta * t)
Этот код определяет класс Model
, который наследуется от класса nn.Module
в PyTorch. В методе init
мы определяем обучаемые параметры alpha
, C1
и C2
, а также фиксированную частоту beta
. В методе forward
определяется прямой проход модели, где мы вычисляем выходное значение output
в зависимости от входной переменной t
с использованием заданных параметров.
Для обучения модели нам нужно написать класс датасет, наследник класса torch.utils.data.Dataset
.
class TimeResponseDataset(Dataset):
"""Torch Dataset for x=time and y=response"""
def __init__(self, time, response):
self.time = torch.Tensor(time)
self.response = torch.Tensor(response)
def __len__(self):
return len(self.time)
def __getitem__(self, idx):
return {'time': self.time[idx], 'response': self.response[idx]}
Он нам нужен, чтобы позже закинуть его в torch.utils.data.DataLoader
. Он то и будет нам выдавать батчи данных во время обучения.
dataset = TimeResponseDataset(time, response)
dataloader = DataLoader(dataset, batch_size=512, shuffle=True)
Шаг 3: Обучение
Давайте посмотрим количество обучаемых параметров в модели.
model = Model()
print(f"trainable params: {len(model.parameters())}")
Результат, как мы и задумывали, равен 3.
Проинициализируем лосс функцию и оптимизатор. Лосс функция будет говорить нам как далеко нам до оптимума, а оптимизатор будет определять как быстро и в каком направлении менять веса, чтобы добраться до него.
lr = 0.01
betas = (0.9, 0.999)
history = []
criterion = nn.MSELoss()
optimizer = torch.optim.Adam(model.parameters(), lr=lr, betas=betas, eps=1e-8)
Теперь обучим модель на 150 эпохах, сохраняя при этом историю лосс функции.
epochs = 150
for epoch in tqdm(range(epochs)):
for data in dataloader:
optimizer.zero_grad()
pred = model(data['time'])
loss = criterion(pred, data['response'])
loss.backward()
optimizer.step()
history.append(loss.detach().numpy())
Здесь мы итерируемся epochs раз по всему датасету. Класс DataLoader
выдает нам стопку (батч) данных . После чего мы подсчитаем, что выдаст нам наша модель, и найдем ошибку между реальными данными и предсказанными. В нашем случае мы выбрали среднеквадратичное отклонение
Но чтобы сделать оптимизационный шаг optimizer.step()
, нам надо сначала обнулить градиенты optimizer.zero_grad()
и вычислить их вновь по параметрам модели loss.backward()
.
Здесь приведен график лосс функции от количества батчей обучения. Как видно из графика модель сошлась еще на 500 шаге. Давайте посмотрим на обученные параметры и на аналитическое решение.
А зачем нам вообще torch.autograd? Давайте сами найдем выражения для градиентов.
И правда, давайте найдем! Однако если вам не интересна математика, то, пропустив этот пункт, ничего страшного не произойдет.
Но сначала вспомним зачем нам нужен это ваш градиент? Объясняю. Градиент - это вектор состоящий из частных производных функции. И каждая компонента градиента показывает в каком направлении функция растет в разрезе этой компоненты. И если собрать все эти производные в один вектор, мы получим вектор наибыстрейшего роста. Умножим на минус и получим направление куда бы нам хотелсь пойти, чтобы достичь минимума.
Минимум лосс функции - это минимум ошибки нашей модели. То есть чем меньше лосс функция, тем меньше наша модель ошибается.
Вспомним еще раз как выглядит наша модель
И мы хотим найти
А сами частные производные равны
Теперь мы можем определить градиент в каждой точке тренировочного датасета. Можно заметить, что в формулах стоит сумма от 1 до N, однако в реальных задачах бывает сложно за раз подсчитать градиент по всему датасету, поэтому обычно берут случайную выборку (батч) из датасета и усредняют градиент по нему.
Теперь как сделать оптимизационный шаг? Это уже вопрос к оптимизатору. Самый простой из них это стохастический градиентый спуск (SGD)
где это скорость обучения, более известный как learning rate. Однако в нашем случае мы используем адаптивный алгоритм оптимизации Adam.
Шаг 4: Сравнение
analytic.alpha, analytic.c_1, analytic.c_2
estimation = [float(param.data) for param in model.parameters()]
estimation
Ошибка меньше . Давайте посмотрим на то, как хорошо легла наша модель под реальные данные.
approx = Analytic(
alpha=estimation[0],
beta=model.beta,
c_1=estimation[1],
c_2=estimation[2]
)
# Analytic, we defined earlier
plt.plot(time, analytic(time), color[0],
label="$y=4/\sqrt{3} \cdot e^{-t/2} sin(\sqrt{3}t/2) - 2 e^{-t/2} cos(\sqrt{3}t/2)$.")
# Approximation
plt.plot(time, approx(time), color[2], linestyle='--', label='Approximation')
Чтобы осознать как быстро сходится модель на этой задаче, можно посмотреть на графики ниже. Это сравнение аналитического решения и нашей модели на эпохах 0, 10, 20
.
Дополнительно
Как вы могли заметить, у нас не все параметры в модели были обучаемыми. Коэффициент , что должен был стоять в периодических функциях, был сразу же зафиксирован. Зачем это было сделано? Показываю.
Объясняю. Косинус и синус, которые стоят в нашей аппроксимирующей модели
очень сильно изменяют форму лосс функции над пространством параметров, делая ее более неприятной для методов градиентного спуска (практически всех оптимизаторов). Это связано с их периодическим характером и быстрыми изменениями, которые могут приводить к локальным минимумам и плохой сходимости алгоритмов оптимизации.
Наша модель сошлась к таким значениям
То есть буквально модель приняла вид , что далека от нами задуманной.
Послесловие
Преимуществом торча в данной задаче является модуль autograd, который сам под капотом считает градиенты для оптимизационного шага. Используя дифференцируемые функции в прямом проходе модели, можно не волноваться об обратном распространении ошибки и писать сколь угодно большие архитектуры. Однако недостатком любой модели, конечно, является её ограниченность архитектуры, поэтому возможность "аппроксимации любой функции" ограничена вашим воображением. Смело генерируйте идеи и проверяйте их с помощью гибкой библиотеки PyTorch.
Надеюсь вам было легко читать этот материал. Полный код вы можете найти на кагле, а меня в телеграме. Буду рад поправкам и комментариям.
Комментарии (6)
imageman
19.04.2024 05:00+1А зачем ты начал с "[возьмём] линейное неоднородное дифференциальное уравнение"? Ведь чуть позже мы понимаем, что это не важно для статьи (а вот народ всякими там Эйлерами и упоминанием комплексных чисел может и отпугнуть).
Коротко: берем экспериментальные данные [x,y], задаем вид функции f(x) с неизвестными коэффициентами A и находим такие коэффициенты, которые дадут наименьшую ошибку. (Кстати, ты ведь не сказал, что вектор X может иметь размерность больше 1. И с некоторыми оговорками и Y может иметь размерность больше 1.)
Дополнительное чтение тут: https://habr.com/ru/companies/skillfactory/articles/666040/
dyadyaSerezha
Я бегло прошёлся и не понял одного. Апроксимация, это представление сложной функции более простой. Тут разве такое же?
dimkablin Автор
Да, ты прав. В примере выше эта правда больше похоже на интерполяцию, чем на аппроксимацию. Мне стоило добавить шума к начальным данным, так было бы более правильно.
В целом в данном посте я хотел показать, как можно использовать торч для построения протых моделей и не прописывать ручками взятие производных для обратного распространения ошибки.
Спасибо за замечание, оно по делу