1 Neural ODE — 연속 시간 역학으로 종단 데이터 모델링
1.1 1. 왜 Neural ODE인가
1.1.1 이산 시간 모델의 한계
25편(LSTM/GRU), 26편(TCN), 27편(Transformer)에서 다룬 시퀀스 모델들은 공통적으로 이산 시점(discrete time step) 을 전제한다. 즉, 입력이 \((x_1, x_2, \ldots, x_T)\)처럼 등간격(equally spaced) 으로 주어진다고 가정한다.
그러나 실제 종단 데이터는 다음과 같은 구조를 갖는 경우가 훨씬 많다.
| 사용자 | 측정 시점 (일) | 만족도 |
|---|---|---|
| A | 1, 3, 7, 30 | 4.2, 4.5, 3.8, 4.1 |
| B | 1, 2, 5, 10, 14 | 3.9, 4.0, 4.3, 4.1, 4.5 |
| C | 1, 7, 28 | 3.5, 3.7, 4.0 |
- 불규칙 시간 간격: 사용자마다 측정 시점이 다르다.
- 측정 횟수 불일치: 사용자 A는 4회, B는 5회, C는 3회.
- 결측(missingness): 특정 시점에 데이터가 없는 것이 자연스러운 상태.
LSTM이나 TCN에 이런 데이터를 넣으려면 보간(interpolation) 이나 패딩(padding) 으로 등간격 시퀀스를 인위적으로 만들어야 한다. 이 과정에서 정보 손실이 발생하고, 시간 간격의 의미가 왜곡된다.
1.1.2 연속 시간의 자연스러운 표현
Neural ODE(Neural Ordinary Differential Equation) 는 시간을 연속 변수로 취급한다. 상태 \(h(t)\)가 미분 방정식을 따라 연속적으로 진화한다고 모델링하므로, 임의의 시점에서 상태를 쿼리할 수 있다.
\[ \frac{dh}{dt} = f_\theta(h(t), t) \]
이 관점에서는 \(t=1\)이든 \(t=3.7\)이든 \(t=14.2\)이든 상관없이, ODE를 해당 시점까지 적분하면 상태를 얻는다. 등간격 가정이 필요 없다.
1.1.3 핵심 논문
Chen et al. (2018), “Neural Ordinary Differential Equations”, NeurIPS 2018 Best Paper. 이 논문이 Neural ODE를 제안하면서 딥러닝과 미분방정식의 연결을 재조명했다. 핵심 기여:
- ResNet의 이산 레이어를 연속 ODE로 일반화
- Adjoint Method로 메모리 효율적 역전파
- 연속 시간 시계열 모델링에 대한 새로운 패러다임
1.2 2. ODE 기초 복습
1.2.1 상미분방정식 (ODE)
상미분방정식(Ordinary Differential Equation)은 미지 함수의 도함수를 포함하는 방정식이다.
\[ \frac{dh}{dt} = f(h(t), t), \quad h(t_0) = h_0 \]
여기서:
- \(h(t) \in \mathbb{R}^d\): 시각 \(t\)에서의 상태 벡터
- \(f: \mathbb{R}^d \times \mathbb{R} \to \mathbb{R}^d\): 상태의 변화율을 정의하는 함수
- \(h_0\): 초기 조건 (initial condition)
이것을 초기값 문제 (Initial Value Problem, IVP) 라 부른다. 초기 상태 \(h_0\)가 주어지면, \(f\)를 따라 미래 시점의 상태 \(h(t_1)\)을 구할 수 있다.
\[ h(t_1) = h(t_0) + \int_{t_0}^{t_1} f(h(t), t) \, dt \]
1.2.2 수치 적분법
해석적으로 적분이 불가능한 경우(대부분), 수치 적분(numerical integration) 을 사용한다.
1.2.2.1 Euler Method (1차)
가장 단순한 방법. 접선 방향으로 한 걸음씩 전진한다.
\[ h(t + \Delta t) = h(t) + \Delta t \cdot f(h(t), t) \]
직관: 현재 기울기를 계산하고, 그 방향으로 \(\Delta t\)만큼 이동.
1.2.2.2 Runge-Kutta 4차 (RK4)
Euler보다 정밀한 표준 방법. 한 스텝 안에서 기울기를 4번 계산한다.
\[ \begin{aligned} k_1 &= f(h_n, t_n) \\ k_2 &= f(h_n + \frac{\Delta t}{2} k_1, \; t_n + \frac{\Delta t}{2}) \\ k_3 &= f(h_n + \frac{\Delta t}{2} k_2, \; t_n + \frac{\Delta t}{2}) \\ k_4 &= f(h_n + \Delta t \cdot k_3, \; t_n + \Delta t) \\ h_{n+1} &= h_n + \frac{\Delta t}{6}(k_1 + 2k_2 + 2k_3 + k_4) \end{aligned} \]
1.2.2.3 Adaptive Solvers: Dormand-Prince (dopri5)
스텝 크기를 자동 조절하는 적응형 방법. 변화가 급격한 구간에서는 작은 스텝, 완만한 구간에서는 큰 스텝을 사용한다. torchdiffeq의 기본 솔버가 dopri5이다.
1.2.3 ResNet과 ODE의 연결
ResNet의 잔차 블록(residual block):
\[ h_{l+1} = h_l + f_\theta(h_l) \]
이것은 Euler Method에서 \(\Delta t = 1\)로 놓은 것과 정확히 같다.
\[ h(t + 1) = h(t) + f_\theta(h(t), t) \quad \leftarrow \text{Euler with } \Delta t = 1 \]
즉, \(L\)개 레이어를 가진 ResNet은 \([0, L]\) 구간을 \(L\)등분한 Euler 이산화로 볼 수 있다. Neural ODE는 이 이산화를 제거하고 연속 깊이(continuous depth) 를 갖는 네트워크를 정의한다.
ResNet: h_0 → [Block 1] → h_1 → [Block 2] → h_2 → ... → h_L
↓ (연속화)
Neural ODE: h(0) → ODESolve(f_θ, h(0), 0, T) → h(T)
1.3 3. Neural ODE 핵심 구조
1.3.1 ODESolve를 레이어로 사용
Neural ODE의 핵심 아이디어: ODE 수치 적분을 하나의 신경망 레이어로 취급한다.
입력: h(t_0)
↓
ODESolve(f_θ, h(t_0), t_0, t_1)
↓
출력: h(t_1)
여기서 \(f_\theta\)는 신경망(MLP 등)으로 매개변수화된 함수이다. Forward pass는 \(t_0\)에서 \(t_1\)까지 ODE를 적분하는 것이다.
1.3.2 Forward Pass
주어진 초기 상태 \(h(t_0)\)에서 목표 시점 \(t_1\)까지의 상태를 계산한다.
\[ h(t_1) = h(t_0) + \int_{t_0}^{t_1} f_\theta(h(t), t) \, dt = \texttt{ODESolve}(f_\theta, h(t_0), t_0, t_1) \]
복수 시점에서의 상태도 한 번의 적분으로 얻을 수 있다.
\[ h(t_1), h(t_2), \ldots, h(t_K) = \texttt{ODESolve}(f_\theta, h(t_0), [t_0, t_1, \ldots, t_K]) \]
이것이 불규칙 시점을 자연스럽게 처리하는 핵심 메커니즘이다.
1.3.3 Python: 기본 Neural ODE 정의
import torch
import torch.nn as nn
from torchdiffeq import odeint
class ODEFunc(nn.Module):
"""dh/dt = f_θ(h, t)를 정의하는 신경망"""
def __init__(self, hidden_dim):
super().__init__()
self.net = nn.Sequential(
nn.Linear(hidden_dim, 64),
nn.Tanh(),
nn.Linear(64, 64),
nn.Tanh(),
nn.Linear(64, hidden_dim),
)
def forward(self, t, h):
# torchdiffeq 규약: forward(t, h)
return self.net(h)
class NeuralODE(nn.Module):
"""ODESolve를 레이어로 사용하는 모델"""
def __init__(self, input_dim, hidden_dim, output_dim):
super().__init__()
self.encoder = nn.Linear(input_dim, hidden_dim)
self.ode_func = ODEFunc(hidden_dim)
self.decoder = nn.Linear(hidden_dim, output_dim)
def forward(self, x0, t_eval):
"""
x0: 초기 관측 (batch, input_dim)
t_eval: 상태를 계산할 시점들 (num_times,)
"""
h0 = self.encoder(x0) # (batch, hidden_dim)
# ODE 적분: t_eval의 모든 시점에서 상태 반환
# 반환: (num_times, batch, hidden_dim)
h_t = odeint(self.ode_func, h0, t_eval, method='dopri5')
# 각 시점에서 출력 디코딩
out = self.decoder(h_t) # (num_times, batch, output_dim)
return out
# 사용 예시
model = NeuralODE(input_dim=3, hidden_dim=32, output_dim=1)
x0 = torch.randn(16, 3) # 배치 16, 초기 특성 3개
t_eval = torch.tensor([0., 1., 3., 7., 14., 30.]) # 불규칙 시점
predictions = model(x0, t_eval) # (6, 16, 1)torchdiffeq.odeint가 핵심이다. 내부에서 적응형 솔버(dopri5)가 자동으로 스텝 크기를 결정하면서 ODE를 적분한다.
1.4 4. Adjoint Method — 메모리 효율적 역전파
1.4.1 왜 Adjoint가 필요한가
일반적인 역전파(backpropagation through the solver)는 ODE 솔버의 모든 중간 상태를 저장해야 한다.
- 솔버가 \(N\)개의 중간 스텝을 사용하면, 메모리 \(O(N \times d)\) 필요.
- 적응형 솔버는 \(N\)이 수백~수천이 될 수 있음.
- 메모리 폭발: 깊은 네트워크와 마찬가지 문제.
Adjoint Method는 중간 상태를 저장하지 않고도 그래디언트를 계산할 수 있다. 메모리 \(O(d)\) — 상태 차원에만 비례.
1.4.2 수학적 유도
손실 함수를 \(L(h(t_1))\)이라 하자. 우리가 계산하고 싶은 것은 \(\frac{dL}{d\theta}\) (파라미터에 대한 그래디언트)이다.
Adjoint state \(a(t)\)를 다음과 같이 정의한다:
\[ a(t) = -\frac{\partial L}{\partial h(t)} \in \mathbb{R}^d \]
이 adjoint는 시간 역방향으로 다음 ODE를 만족한다:
\[ \frac{da}{dt} = -a(t)^T \frac{\partial f_\theta(h(t), t)}{\partial h} \]
파라미터에 대한 그래디언트도 역방향 ODE로 계산한다:
\[ \frac{dL}{d\theta} = -\int_{t_1}^{t_0} a(t)^T \frac{\partial f_\theta(h(t), t)}{\partial \theta} \, dt \]
1.4.3 증강 상태 (Augmented State)
실제 구현에서는 \(h(t)\), \(a(t)\), \(\frac{dL}{d\theta}\)를 하나의 증강 상태 벡터로 묶어서 역방향 ODE 하나로 동시에 적분한다.
\[ \frac{d}{dt} \begin{pmatrix} h \\ a \\ \frac{dL}{d\theta} \end{pmatrix} = \begin{pmatrix} f_\theta(h, t) \\ -a^T \frac{\partial f}{\partial h} \\ -a^T \frac{\partial f}{\partial \theta} \end{pmatrix} \]
시간 \(t_1\)에서 시작하여 \(t_0\)까지 역적분하면 그래디언트를 얻는다.
1.4.4 메모리 비교
| 방법 | 메모리 복잡도 | 계산 복잡도 |
|---|---|---|
| Backprop through solver | \(O(N \times d)\) | \(O(N)\) |
| Adjoint Method | \(O(d)\) | \(O(N) + \alpha\) (역방향 ODE) |
- Adjoint 장점: 메모리 \(O(1)\) (솔버 스텝 수 \(N\)에 무관).
- Adjoint 단점: 역방향 ODE를 추가로 풀어야 하므로 계산 시간은 증가. 또한 수치 오차 누적 가능.
- Trade-off: 메모리가 부족할 때 Adjoint, 그렇지 않으면 backprop through solver가 더 정확.
torchdiffeq에서는 odeint_adjoint를 사용하면 자동으로 Adjoint Method가 적용된다.
from torchdiffeq import odeint_adjoint
# odeint 대신 odeint_adjoint 사용 → O(1) 메모리
h_t = odeint_adjoint(ode_func, h0, t_eval, method='dopri5')1.5 5. Latent ODE for 종단 데이터
1.5.1 왜 Latent ODE인가
기본 Neural ODE는 초기 상태 \(h(t_0)\)에서 미래 궤적을 결정한다. 그러나 종단 데이터에서는:
- 초기 관측이 노이즈를 포함한다.
- 여러 시점의 관측을 종합하여 잠재 상태를 추론해야 한다.
- 개인 간 변이(between-subject variability) 를 표현해야 한다.
Latent ODE (Rubanova et al., 2019)는 Variational Autoencoder(VAE) 프레임워크 안에서 Neural ODE를 사용한다.
1.5.2 모델 구조
관측 시퀀스: (x_{t_1}, x_{t_2}, ..., x_{t_K})
↓
[Encoder: ODE-RNN]
↓
잠재 초기 상태: q(z_0 | x_{1:K}) = N(μ_z0, σ²_z0)
↓ (reparameterization trick)
z_0 ~ q(z_0)
↓
[ODE Dynamics: dz/dt = f_θ(z(t), t)]
↓ ODESolve
z(t_1), z(t_2), ..., z(t_K)
↓
[Decoder]
↓
x̂(t_1), x̂(t_2), ..., x̂(t_K)
각 구성 요소:
- Encoder (ODE-RNN): 관측 시퀀스를 역방향으로 처리하여 잠재 초기 상태 \(z_0\)의 분포 \(q(z_0 | x_{1:K})\)를 추론. 불규칙 시점 간의 이동에 ODE를 사용 (관측 시점에서만 RNN 업데이트).
- Latent Dynamics: \(z_0\)에서 출발하여 Neural ODE로 잠재 궤적을 생성.
- Decoder: 각 시점의 잠재 상태 \(z(t_k)\)를 관측 공간으로 변환.
1.5.3 ODE-RNN Encoder
일반 RNN은 등간격을 가정하지만, ODE-RNN은 관측 사이의 시간 간격을 ODE로 처리한다.
\[ \begin{aligned} \tilde{h}_i &= \texttt{ODESolve}(f_\theta, h_{i-1}, t_{i-1}, t_i) \quad &\text{(ODE로 시간 전진)} \\ h_i &= \texttt{RNNCell}(\tilde{h}_i, x_i) \quad &\text{(관측으로 상태 업데이트)} \end{aligned} \]
이렇게 하면 관측 사이의 시간 간격이 얼마든 상관없이 처리할 수 있다.
1.5.4 손실 함수: ELBO
VAE 프레임워크이므로 Evidence Lower Bound(ELBO)를 최대화한다.
\[ \mathcal{L} = \underbrace{\sum_{k=1}^{K} \mathbb{E}_{q(z_0)} \left[ \log p(x_{t_k} | z(t_k)) \right]}_{\text{reconstruction}} - \underbrace{D_{\text{KL}}\left( q(z_0 | x_{1:K}) \| p(z_0) \right)}_{\text{regularization}} \]
- Reconstruction term: 잠재 궤적에서 디코딩한 값이 실제 관측과 일치하도록.
- KL term: 잠재 초기 상태의 사후 분포가 사전 분포(보통 표준 정규)와 가까워지도록.
1.5.5 Mixed Model과의 연결
Latent ODE는 개념적으로 비선형 혼합 효과 모델(Nonlinear Mixed Effects Model) 의 딥러닝 버전으로 볼 수 있다.
| 개념 | Mixed Model | Latent ODE |
|---|---|---|
| 개인별 변이 | Random Effects \(b_i\) | 잠재 초기 상태 \(z_0^{(i)}\) |
| 시간 역학 | 선형 or 지정된 비선형 함수 | Neural ODE \(f_\theta\) (유연한 비선형) |
| 추정 | ML/REML | ELBO (VAE) |
| 시점 제약 | 없음 (연속) | 없음 (연속) |
차이점: Mixed Model은 역학 함수의 형태를 미리 지정하지만, Latent ODE는 \(f_\theta\)를 데이터에서 학습한다.
1.6 6. Python 실무 예시 — AI Agent 만족도 궤적 예측
1.6.1 시나리오
AI Agent 개인화 실험. 500명의 사용자가 30일간 불규칙 시점에서 만족도를 보고한다. 사용자별 측정 횟수는 3~8회. 목표: 초기 관측으로부터 30일 후 만족도 궤적을 예측한다.
1.6.2 데이터 생성
import torch
import numpy as np
import matplotlib.pyplot as plt
np.random.seed(42)
torch.manual_seed(42)
n_users = 500
# 사용자별 불규칙 시점 생성
data = []
for i in range(n_users):
n_obs = np.random.randint(3, 9) # 3~8회 관측
times = np.sort(np.random.choice(range(1, 31), size=n_obs, replace=False)).astype(float)
# 개인별 기저 만족도와 변화 패턴
baseline = np.random.normal(4.0, 0.5)
trend = np.random.normal(0.01, 0.02) # 개인별 선형 추세
amplitude = np.random.normal(0.3, 0.1) # 비선형 변동 크기
# 궤적: 기저 + 추세 + 비선형 패턴 + 노이즈
y = baseline + trend * times + amplitude * np.sin(times * 0.3) + np.random.normal(0, 0.2, n_obs)
data.append({
'user_id': i,
'times': torch.tensor(times, dtype=torch.float32),
'values': torch.tensor(y, dtype=torch.float32),
})
print(f"사용자 수: {n_users}")
print(f"사용자 0: 시점 {data[0]['times'].numpy()}, 값 {data[0]['values'].numpy().round(2)}")1.6.3 Latent ODE 모델 구현
import torch
import torch.nn as nn
from torchdiffeq import odeint
# ---- ODE 동역학 함수 ----
class LatentODEFunc(nn.Module):
"""dz/dt = f_θ(z)"""
def __init__(self, latent_dim):
super().__init__()
self.net = nn.Sequential(
nn.Linear(latent_dim, 50),
nn.ELU(),
nn.Linear(50, 50),
nn.ELU(),
nn.Linear(50, latent_dim),
)
def forward(self, t, z):
return self.net(z)
# ---- ODE-RNN Encoder ----
class ODERNNEncoder(nn.Module):
"""불규칙 시점의 관측 시퀀스 → 잠재 초기 상태 q(z0)"""
def __init__(self, input_dim, hidden_dim, latent_dim):
super().__init__()
self.hidden_dim = hidden_dim
self.latent_dim = latent_dim
# 관측 사이의 시간 진화를 위한 ODE
self.ode_func = nn.Sequential(
nn.Linear(hidden_dim, 50),
nn.Tanh(),
nn.Linear(50, hidden_dim),
)
# 관측 시점에서의 GRU 업데이트
self.gru_cell = nn.GRUCell(input_dim, hidden_dim)
# z0 분포 파라미터
self.fc_mu = nn.Linear(hidden_dim, latent_dim)
self.fc_logvar = nn.Linear(hidden_dim, latent_dim)
def ode_step(self, t, h):
return self.ode_func(h)
def forward(self, times, values):
"""
times: (seq_len,) - 관측 시점 (역순)
values: (batch, seq_len, input_dim) - 관측값 (역순)
"""
batch_size = values.shape[0]
h = torch.zeros(batch_size, self.hidden_dim, device=values.device)
# 시간 역순으로 처리 (마지막 관측부터)
for i in range(len(times) - 1):
# GRU 업데이트: 관측 정보 반영
h = self.gru_cell(values[:, i, :], h)
# ODE 적분: 다음 관측 시점까지 시간 역방향 이동
dt = times[i] - times[i + 1] # 역순이므로 양수
t_span = torch.tensor([0., dt])
h = odeint(self.ode_step, h, t_span, method='euler')[-1]
# 마지막 관측 처리
h = self.gru_cell(values[:, -1, :], h)
# z0 분포 파라미터
mu = self.fc_mu(h)
logvar = self.fc_logvar(h)
return mu, logvar
# ---- Decoder ----
class Decoder(nn.Module):
"""잠재 상태 → 관측 공간"""
def __init__(self, latent_dim, output_dim):
super().__init__()
self.net = nn.Sequential(
nn.Linear(latent_dim, 50),
nn.ReLU(),
nn.Linear(50, output_dim),
)
def forward(self, z):
return self.net(z)
# ---- 전체 Latent ODE 모델 ----
class LatentODEModel(nn.Module):
def __init__(self, input_dim=1, hidden_dim=64, latent_dim=16, output_dim=1):
super().__init__()
self.encoder = ODERNNEncoder(input_dim, hidden_dim, latent_dim)
self.ode_func = LatentODEFunc(latent_dim)
self.decoder = Decoder(latent_dim, output_dim)
self.latent_dim = latent_dim
def reparameterize(self, mu, logvar):
std = torch.exp(0.5 * logvar)
eps = torch.randn_like(std)
return mu + eps * std
def forward(self, obs_times, obs_values, eval_times):
"""
obs_times: (seq_len,) - 관측 시점 (역순)
obs_values: (batch, seq_len, 1) - 관측값 (역순)
eval_times: (num_eval,) - 예측할 시점
"""
# Encode
mu, logvar = self.encoder(obs_times, obs_values)
z0 = self.reparameterize(mu, logvar) # (batch, latent_dim)
# ODE dynamics
z_t = odeint(self.ode_func, z0, eval_times, method='dopri5')
# z_t: (num_eval, batch, latent_dim)
# Decode
pred = self.decoder(z_t) # (num_eval, batch, output_dim)
return pred, mu, logvar
# 모델 초기화
model = LatentODEModel(input_dim=1, hidden_dim=64, latent_dim=16, output_dim=1)
print(f"파라미터 수: {sum(p.numel() for p in model.parameters()):,}")1.6.4 학습 루프
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
n_epochs = 100
def vae_loss(pred, target, mu, logvar, kl_weight=0.1):
"""ELBO loss = Reconstruction + KL divergence"""
recon_loss = nn.functional.mse_loss(pred, target)
kl_loss = -0.5 * torch.mean(1 + logvar - mu.pow(2) - logvar.exp())
return recon_loss + kl_weight * kl_loss, recon_loss, kl_loss
for epoch in range(n_epochs):
total_loss = 0.
model.train()
for user_data in data:
times = user_data['times']
values = user_data['values']
# 역순 정렬 (encoder에 역순으로 입력)
idx = torch.argsort(times, descending=True)
rev_times = times[idx]
rev_values = values[idx].unsqueeze(0).unsqueeze(-1) # (1, seq_len, 1)
# 정순 시점으로 예측
eval_times = times.sort()[0]
target = values[times.sort()[1]].unsqueeze(0).unsqueeze(-1) # (1, seq_len, 1)
# Forward
pred, mu, logvar = model(rev_times, rev_values, eval_times)
pred = pred.squeeze(-1).T.unsqueeze(-1) # shape 맞춤
# Loss
loss, recon, kl = vae_loss(pred, target, mu, logvar)
optimizer.zero_grad()
loss.backward()
optimizer.step()
total_loss += loss.item()
if (epoch + 1) % 20 == 0:
print(f"Epoch {epoch+1:3d} | Loss: {total_loss/n_users:.4f}")1.6.5 궤적 시각화
model.eval()
fig, axes = plt.subplots(2, 3, figsize=(14, 8))
for idx, ax in enumerate(axes.flat):
user = data[idx]
times = user['times']
values = user['values']
# 예측: 0~30일 연속 궤적
t_fine = torch.linspace(0, 30, 200)
with torch.no_grad():
rev_idx = torch.argsort(times, descending=True)
rev_times = times[rev_idx]
rev_values = values[rev_idx].unsqueeze(0).unsqueeze(-1)
pred, _, _ = model(rev_times, rev_values, t_fine)
pred = pred.squeeze() # (200,)
ax.plot(t_fine.numpy(), pred.numpy(), 'b-', alpha=0.7, label='Neural ODE 예측')
ax.scatter(times.numpy(), values.numpy(), c='red', s=40, zorder=5, label='실제 관측')
ax.set_xlabel('일 (day)')
ax.set_ylabel('만족도')
ax.set_title(f'사용자 {idx}')
ax.legend(fontsize=8)
ax.set_ylim(2.5, 5.5)
plt.suptitle('AI Agent 사용자 만족도 — Latent ODE 궤적 예측', fontsize=14)
plt.tight_layout()
plt.savefig('neural_ode_trajectories.png', dpi=150)
plt.show()1.6.6 R 개념적 코드
R에서는 Neural ODE 전용 패키지가 성숙하지 않았으나, deSolve + torch (R torch)를 조합하거나, diffeqr (Julia 백엔드)을 사용할 수 있다.
# R에서의 개념적 ODE 풀이 (deSolve)
library(deSolve)
# 간단한 ODE 시스템: 만족도 역학
satisfaction_ode <- function(t, state, parameters) {
with(as.list(c(state, parameters)), {
# dh/dt = alpha * (target - h) + beta * sin(omega * t)
dh <- alpha * (target - h) + beta * sin(omega * t)
list(c(dh))
})
}
# 개인별 파라미터 (혼합 효과 역할)
params <- c(alpha = 0.1, target = 4.2, beta = 0.3, omega = 0.3)
state <- c(h = 3.8)
# 불규칙 시점에서 풀기
times <- c(0, 1, 3, 7, 14, 30)
out <- ode(y = state, times = times, func = satisfaction_ode, parms = params)
print(out)
# 참고: 실제 Neural ODE는 f_θ를 신경망으로 대체
# R에서 본격적으로 하려면:
# - diffeqr: Julia DifferentialEquations.jl 백엔드
# - torch::nn_module + 수동 ODE 풀이1.7 7. ODE-RNN vs Latent ODE vs Neural CDE 비교
1.7.1 변형 모델들
Neural ODE를 종단 데이터에 적용하는 여러 변형이 있다. 각각 어떤 문제를 해결하느냐가 다르다.
1.7.2 ODE-RNN (Rubanova et al., 2019)
- 구조: RNN + ODE. 관측 사이를 ODE로 연결, 관측 시점에서 RNN 업데이트.
- 장점: 불규칙 시점 처리. 구현이 비교적 간단.
- 단점: 순차적 처리(병렬화 어려움). 전체 궤적의 불확실성을 표현하지 못함.
- 용도: 불규칙 시점 + 순차적 의사결정이 필요할 때.
1.7.3 Latent ODE (Rubanova et al., 2019)
- 구조: VAE + Neural ODE. 잠재 공간에서 ODE 역학.
- 장점: 불확실성 표현(사후 분포). 잠재 궤적의 보간/외삽. 결측 자연스럽게 처리.
- 단점: 초기 상태에 모든 정보를 압축 → 긴 시퀀스에서 정보 손실 가능. KL collapse 위험.
- 용도: 궤적 전체를 생성/예측해야 할 때. 개인 간 변이가 중요할 때.
1.7.4 Neural CDE (Kidger et al., 2020)
- 구조: 제어 미분방정식(Controlled Differential Equation) 기반.
\[ \frac{dz}{dt} = f_\theta(z(t)) \cdot \frac{dX}{dt} \]
여기서 \(X(t)\)는 관측 경로(path)의 보간. \(\frac{dX}{dt}\)가 제어 신호(control signal) 역할을 한다.
- 장점: 새로운 관측이 들어올 때마다 경로에 반영 → 온라인 업데이트에 가까움. 이론적으로 RNN의 연속 시간 일반화.
- 단점: 경로 보간 방법(linear, cubic spline 등) 선택이 결과에 영향.
- 용도: 스트리밍 데이터. 관측 경로 자체가 중요한 정보를 담고 있을 때.
1.7.5 비교 요약
| 속성 | ODE-RNN | Latent ODE | Neural CDE |
|---|---|---|---|
| 불규칙 시점 | O | O | O |
| 불확실성 표현 | X | O (VAE) | X (기본) |
| 새 관측 반영 | RNN 업데이트 | 재인코딩 필요 | 경로에 자연스럽게 반영 |
| 메모리 효율 | 보통 | Adjoint 사용 시 좋음 | Adjoint 사용 시 좋음 |
| 구현 난이도 | 낮음 | 중간 | 중간~높음 |
| 대표 라이브러리 | torchdiffeq | torchdiffeq | torchcde |
| 적합 상황 | 단순 불규칙 시점 | 궤적 생성/예측, 개인 변이 | 스트리밍, 경로 의존적 |
1.7.6 선택 가이드
불규칙 시점 종단 데이터?
├── Yes
│ ├── 궤적 전체를 생성/예측? → Latent ODE
│ ├── 실시간 관측 반영? → Neural CDE
│ └── 단순 분류/회귀? → ODE-RNN
└── No (등간격)
└── LSTM, TCN, Transformer가 더 간단하고 빠름
1.8 8. 한계와 실무 팁
1.8.1 Stiff ODE 문제
Stiff ODE: 서로 다른 시간 스케일이 공존하는 시스템. 빠르게 변하는 성분과 느리게 변하는 성분이 동시에 존재.
dopri5(명시적 방법)는 stiff ODE에서 극도로 작은 스텝을 사용하게 되어 느려짐.- 해결:
implicit_adams등 암시적(implicit) 솔버 사용. 또는scipy_solver래퍼.
1.8.2 솔버 선택 가이드
| 솔버 | 특징 | 권장 상황 |
|---|---|---|
dopri5 |
적응형, 범용, 기본값 | 대부분의 상황 |
euler |
고정 스텝, 빠름, 부정확 | 디버깅, 빠른 프로토타이핑 |
rk4 |
고정 스텝, 4차 정밀도 | 스텝 수를 직접 제어하고 싶을 때 |
implicit_adams |
암시적, stiff ODE용 | 시스템이 stiff할 때 |
scipy_solver |
SciPy 백엔드 | CPU에서 안정성이 중요할 때 |
1.8.3 학습 불안정성 대처
Neural ODE 학습은 일반 딥러닝보다 까다롭다. 흔한 문제와 대응:
- NFE(Number of Function Evaluations) 폭발: 솔버가 수렴을 위해 너무 많은 스텝을 사용.
- 해결: NFE를 모니터링하고, 정규화 추가 (
kinetic_energyregularization).
- 해결: NFE를 모니터링하고, 정규화 추가 (
- 그래디언트 소실/폭발: ODE 적분 구간이 길면 발생.
- 해결: 적분 구간 분할, 또는 그래디언트 클리핑.
- 느린 학습: ODE 적분이 매 forward pass마다 실행되므로 epoch당 시간이 김.
- 해결:
euler솔버로 빠르게 프로토타이핑 후dopri5로 전환.
- 해결:
# NFE 모니터링 예시
class ODEFuncWithNFE(nn.Module):
def __init__(self, hidden_dim):
super().__init__()
self.net = nn.Sequential(
nn.Linear(hidden_dim, 64),
nn.Tanh(),
nn.Linear(64, hidden_dim),
)
self.nfe = 0 # function evaluation 카운터
def forward(self, t, h):
self.nfe += 1
return self.net(h)
# 학습 중 NFE 확인
ode_func = ODEFuncWithNFE(32)
ode_func.nfe = 0
h_t = odeint(ode_func, h0, t_eval, method='dopri5')
print(f"Forward pass NFE: {ode_func.nfe}")
# NFE가 1000 이상이면 문제 → 정규화 필요1.8.4 정규화 기법
def kinetic_energy_regularization(ode_func, h0, t_eval, lam=0.01):
"""
f_θ의 크기를 제한하여 솔버 스텝 수를 줄임.
||f_θ(h,t)||² 를 정규화 항으로 추가.
"""
h_t = odeint(ode_func, h0, t_eval, method='dopri5')
# 적분 구간에서 f의 크기 샘플링
t_sample = t_eval[torch.randint(len(t_eval), (5,))]
reg = 0.
for t_s in t_sample:
idx = (t_eval - t_s).abs().argmin()
f_val = ode_func(t_s, h_t[idx])
reg += f_val.pow(2).mean()
return h_t, lam * reg / len(t_sample)1.8.5 계산 비용
| 모델 | 학습 시간 (상대) | 추론 시간 (상대) | 메모리 |
|---|---|---|---|
| LSTM | 1x | 1x | 낮음 |
| TCN | 0.7x | 0.5x | 낮음 |
| Transformer | 1.5x | 1.2x | 중간 |
| Neural ODE (euler) | 2x | 1.5x | 낮음 |
| Neural ODE (dopri5) | 5~10x | 3~5x | Adjoint 시 낮음 |
| Latent ODE | 10~20x | 5~10x | Adjoint 시 중간 |
1.8.6 언제 Neural ODE를 쓰지 말아야 하는가
다음 조건이 모두 해당되면 LSTM이나 TCN이 더 나은 선택이다:
- 등간격 시점: 모든 대상이 같은 시점에서 관측됨.
- 대규모 데이터: 수십만~수백만 샘플. 학습 속도가 중요.
- 단순 분류/회귀: 궤적 전체가 아니라 최종 결과만 필요.
- 해석 불필요: 연속 역학의 해석이 중요하지 않음.
반대로, 다음 상황에서 Neural ODE가 진가를 발휘한다:
- 불규칙 시점, 결측이 많은 데이터
- 연속 시간 궤적의 예측/보간이 목적
- 물리적/생물학적 시스템처럼 연속 역학이 자연스러운 도메인
- 임의의 시점에서 상태를 쿼리해야 하는 경우
1.9 9. 핵심 요약
1.9.1 Neural ODE 한 줄 요약
신경망을 ODE의 우변에 놓아, 이산 레이어 대신 연속 시간 역학을 학습한다.
1.9.2 핵심 포인트
| 항목 | 내용 |
|---|---|
| 핵심 아이디어 | \(\frac{dh}{dt} = f_\theta(h,t)\), ODESolve를 레이어로 사용 |
| ResNet 연결 | ResNet = Euler 이산화된 ODE |
| Adjoint Method | \(O(1)\) 메모리 역전파, trade-off: 계산 시간 증가 |
| Latent ODE | VAE + Neural ODE, 종단 데이터의 비선형 혼합 효과 모델 |
| 불규칙 시점 | ODE 적분이 임의 시점을 자연스럽게 처리 |
| 주요 라이브러리 | torchdiffeq (Python), torchcde (Neural CDE) |
| 권장 상황 | 불규칙 시점, 연속 궤적 예측, 물리/생물학적 시스템 |
| 비권장 상황 | 등간격 + 대규모 + 단순 예측 → LSTM/TCN이 더 효율적 |
1.9.3 시리즈 내 위치
[13 DL Overview] ─┬─ [25 LSTM/GRU]
├─ [26 TCN]
├─ [27 Transformer]
└─ [28 Neural ODE] ← 현재 파일
1.9.4 관련 파일
- 13 — DL Overview: 딥러닝 기법 전체 개요
- 25 — LSTM/GRU: 게이트 기반 순환 신경망
- 26 — TCN: Temporal Convolutional Network
- 27 — Transformer: 어텐션 기반 시퀀스 모델
- 01 — LMM 입문: 선형 혼합 모델 (Latent ODE와 개념적 연결)
1.9.5 참고 문헌
- Chen, R.T.Q. et al. (2018). Neural Ordinary Differential Equations. NeurIPS.
- Rubanova, Y. et al. (2019). Latent ODEs for Irregularly-Sampled Time Series. NeurIPS.
- Kidger, P. et al. (2020). Neural Controlled Differential Equations for Irregular Time Series. NeurIPS.
- Dupont, E. et al. (2019). Augmented Neural ODEs. NeurIPS.
- Finlay, C. et al. (2020). How to Train Your Neural ODE: the World of Jacobian and Kinetic Regularization. ICML.