Guía de Trabajo Práctico 4#

Materia: Aprendizaje Profundo Basado en la Física (optativa del Instituto Balseiro)

Docente: José I. Robledo

Edición: abril-mayo 2026

Física Diferenciable con PyTorch#

En esta guía vas a trabajar con simuladores differentiables escritos directamente en PyTorch. La idea central es simple: si la simulación está construida con operaciones diferenciables, entonces podés derivar una función de costo respecto de parámetros físicos, condiciones iniciales o variables de control y usar gradiente descendente para resolver problemas inversos.


Ejercicio 1 — Tiro parabólico y diseño inverso con física diferenciable#

Vamos a trabajar con un proyectil 2D lanzado desde el origen. El estado es (x, y, v_x, v_y) y usaremos el modelo más simple sin rozamiento:

\[ \dot{x} = v_x, \qquad \dot{y} = v_y, \]
\[ \dot{v}_x = 0, \qquad \dot{v}_y = -g. \]

El disparo estará parametrizado por una rapidez inicial v0 y un ángulo theta, de modo que

\[ v_x(0) = v_0 \cos(\theta), \qquad v_y(0) = v_0 \sin(\theta). \]

Como queremos usar autograd para optimizar v0 y theta, toda la simulación debe implementarse con operaciones diferenciables de PyTorch.

Tareas#

  1. Completar projectile_rollout para devolver la trayectoria x(t), y(t) usando Euler explícito.

  2. Simular una trayectoria con v0 = 15, theta = 0.9 rad, dt = 0.02, steps = 180.

  3. Graficar la trayectoria en el plano y(x).

  4. Definir un blanco en (x_target, y_target) = (18, 6).

  5. Usar el simulador diferenciable para ajustar v0 y theta de forma que el proyectil pase lo más cerca posible del blanco.

  6. Graficar la trayectoria inicial, la trayectoria optimizada y el blanco.

Sugerencias:

  • Para asegurar v0 > 0, optimizá un parámetro libre raw_v0 y definí v0 = softplus(raw_v0).

  • Para mantener theta en un rango razonable, podés usar una sigmoide y reescalarla, por ejemplo al intervalo (0.05, 1.45) rad.

  • Como la trayectoria tiene longitud fija, conviene definir la loss usando la menor distancia al blanco a lo largo de todos los pasos temporales.

def projectile_rollout(v0, theta, dt=0.02, steps=180, g=9.81):
    x = torch.tensor(0.0)
    y = torch.tensor(0.0)
    vx = v0 * torch.cos(theta)
    vy = v0 * torch.sin(theta)

    xs = [x]
    ys = [y]

    for _ in range(steps):
        # TODO: actualizar velocidades con la gravedad
        # TODO: actualizar posiciones
        # TODO: guardar x, y
        pass

    # TODO: devolver tensores xs, ys con shape (steps + 1,)
    raise NotImplementedError


x_target = torch.tensor(18.0)
y_target = torch.tensor(6.0)


def closest_distance_loss(xs, ys, x_target, y_target):
    # TODO: calcular la distancia cuadratica a cada punto de la trayectoria
    # TODO: devolver el minimo de esas distancias
    raise NotImplementedError


v0_test = torch.tensor(15.0)
theta_test = torch.tensor(0.9)

# TODO: simular la trayectoria inicial y graficarla

raw_v0 = torch.tensor(2.0, requires_grad=True)
raw_theta = torch.tensor(0.0, requires_grad=True)
optimizer = torch.optim.Adam([raw_v0, raw_theta], lr=0.05)
history = []

for epoch in range(400):
    # TODO: parametrizar v0 y theta
    # TODO: simular la trayectoria
    # TODO: definir la loss respecto del blanco
    # TODO: hacer backward y optimizer.step()
    pass

# TODO: reconstruir trayectoria final y compararla con la inicial

Ejercicio 2 — Inferencia del amortiguamiento a partir de datos#

Para centrarnos en la física diferenciable, volvamos ahora al oscilador amortiguado 1D con masa unitaria y ecuación

\[ \ddot{x}(t) + c\,\dot{x}(t) + k\,x(t) = 0, \]

donde k es la constante del resorte y c el coeficiente de amortiguamiento.

Usaremos un esquema de Euler semiimplícito:

\[ v_{n+1} = v_n + \Delta t\,(-k x_n - c v_n), \qquad x_{n+1} = x_n + \Delta t\, v_{n+1}. \]

Supongamos ahora que mediste una trayectoria x_obs(t) y querés estimar el valor de c. Vamos a generar primero una trayectoria sintética con un valor verdadero c_true y luego vamos a recuperar ese parámetro minimizando el error cuadrático medio entre la trayectoria observada y la simulada.

Tareas#

  1. Completar oscillator_step y simulate_oscillator.

  2. Construir x_obs usando c_true = 0.18.

  3. Introducir ruido gaussiano pequeño para volver el problema un poco más realista.

  4. Definir un parámetro libre raw_c y parametrizar el amortiguamiento como c_hat = softplus(raw_c) para asegurar positividad.

  5. Optimizar raw_c con Adam.

  6. Graficar la historia de la loss y comparar x_obs(t) con la trayectoria reconstruida.

def oscillator_step(x, v, k, c, dt):
    # TODO: calcular la aceleracion a = -k * x - c * v
    # TODO: actualizar velocidad con Euler
    # TODO: actualizar posicion usando la nueva velocidad
    raise NotImplementedError


def simulate_oscillator(x0, v0, k, c, dt, steps):
    xs = [x0]
    vs = [v0]
    x, v = x0, v0
    for _ in range(steps):
        # TODO: avanzar un paso de tiempo con oscillator_step
        # TODO: append de los nuevos estados
        pass
    # TODO: devolver tensores apilados con shape (steps + 1,)
    raise NotImplementedError


x0 = torch.tensor(1.0)
v0 = torch.tensor(0.0)
k = torch.tensor(1.5)
dt = 0.05
steps = 200
c_true = torch.tensor(0.18)
noise_std = 0.01

with torch.no_grad():
    # TODO: generar trayectoria observada libre de ruido
    # TODO: sumar ruido gaussiano pequeno a la posicion observada
    pass

raw_c = torch.tensor(-2.0, requires_grad=True)
optimizer = torch.optim.Adam([raw_c], lr=0.05)
loss_history = []

for epoch in range(400):
    # TODO: convertir raw_c en c_hat usando softplus
    # TODO: simular con c_hat
    # TODO: definir loss MSE contra x_obs
    # TODO: hacer backward y step
    pass

# TODO: reconstruir la mejor trayectoria y graficar resultados

Ejercicio 3 — Sensibilidad respecto de condiciones iniciales y parámetros#

En física diferenciable no sólo interesa ajustar parámetros: también importa entender qué tan sensible es una cantidad de interés ante pequeños cambios en las entradas del simulador.

Tomemos como observable la posición final x(T) del oscilador luego de T = steps * dt.

  1. Repetir la simulación con x0 como tensor con requires_grad=True.

  2. Calcular \partial x(T) / \partial x0 usando autograd.

  3. Repetir el experimento con c como tensor con requires_grad=True y calcular \partial x(T) / \partial c.

  4. Comparar ambas derivadas en valor absoluto e interpretar cuál variable parece más influyente para este horizonte temporal.

  5. Bonus: cambiar el observable por la energía final aproximada E(T) = 0.5 * (v(T)^2 + k x(T)^2) y repetir el análisis.

Este tipo de derivadas es útil para diseño de experimentos, calibración e identificación de parámetros.

x0_sens = torch.tensor(1.0, requires_grad=True)
c_sens = torch.tensor(0.15, requires_grad=True)
k_sens = torch.tensor(1.5)

# TODO: simular con x0_sens y obtener gradiente de x(T) respecto de x0_sens
# TODO: simular con c_sens y obtener gradiente de x(T) respecto de c_sens
# TODO: imprimir ambas sensibilidades y discutir el resultado

Ejercicio 4 — Circuito RC como sistema diferenciable#

Consideremos un circuito RC serie excitado por una fuente constante V_in. Si elegimos como variable de estado la tensión del capacitor V_c(t), el modelo viene dado por

\[ C \, \dot{V}_c = \frac{V_{in} - V_c}{R}, \qquad \Rightarrow \qquad \dot{V}_c = \frac{V_{in} - V_c}{RC}. \]

Este sistema es útil para física diferenciable porque:

  • tiene una dinámica simple pero no trivial;

  • permite plantear inferencia de parámetros;

  • permite estudiar sensibilidad respecto de R, C y la condición inicial.

  1. Completar rc_step y simulate_rc.

  2. Simular la carga del capacitor con R = 2, C = 1.5, V_in = 5, V0 = 0, dt = 0.05, steps = 120.

  3. Graficar V_c(t).

  4. Generar datos sintéticos V_obs(t) con un valor verdadero R_true, agregando ruido pequeño.

  5. Inferir R minimizando el MSE entre V_obs(t) y la trayectoria simulada.

  6. Calcular con autograd las sensibilidades \partial V_c(T)/\partial R y \partial V_c(T)/\partial C.

Preguntas para discutir:

  • ¿Qué parámetro parece afectar más el voltaje final?

  • ¿Cómo cambia la dificultad de inferir R si el horizonte temporal es muy corto?

  • ¿Qué interpretación física tiene la combinación RC en este problema?

def rc_step(vc, R, C, V_in, dt):
    # TODO: calcular dVc/dt = (V_in - vc) / (R * C)
    # TODO: actualizar vc con Euler
    raise NotImplementedError


def simulate_rc(V0, R, C, V_in, dt, steps):
    vcs = [V0]
    vc = V0
    for _ in range(steps):
        # TODO: avanzar un paso con rc_step
        # TODO: guardar el nuevo voltaje
        pass
    # TODO: devolver la trayectoria completa
    raise NotImplementedError


V0 = torch.tensor(0.0)
R = torch.tensor(2.0)
C = torch.tensor(1.5)
V_in = torch.tensor(5.0)
dt = 0.05
steps = 120
t = torch.linspace(0.0, steps * dt, steps + 1)

# TODO: simular y graficar la carga del capacitor

R_true = torch.tensor(1.8)
noise_std = 0.03

with torch.no_grad():
    # TODO: generar trayectoria observada con ruido
    pass

raw_R = torch.tensor(0.0, requires_grad=True)
optimizer = torch.optim.Adam([raw_R], lr=0.05)
loss_history = []

for epoch in range(300):
    # TODO: parametrizar R_hat con softplus
    # TODO: simular el circuito
    # TODO: definir la loss MSE y optimizar
    pass

# TODO: calcular sensibilidades de Vc(T) respecto de R y C
# TODO: graficar historia de la loss y ajuste final

Ejercicio 5 - FD + NN para controlar un circuito RC#

En los ejercicios anteriores, la física diferenciable se usó para ajustar parámetros físicos como R o condiciones de disparo como v0 y theta. Ahora queremos mostrar una idea complementaria: usar una red neuronal como controlador, mientras la evolución temporal del sistema sigue estando dada por la física conocida.

El circuito sigue siendo el mismo:

\[ \dot{V}_c = \frac{V_{in} - V_c}{RC}. \]

Pero en vez de fijar V_in de antemano, vamos a hacer que una red neuronal la produzca en cada paso de tiempo:

\[ V_{in}(t) = \mathrm{NN}_\theta\big(V_c(t), V_{\mathrm{target}}\big). \]

Intención física del problema#

La idea no es “que la red aprenda la física del capacitor”. Esa parte ya la conocemos. La intención es otra:

  • el simulador físico se encarga de imponer la dinámica correcta del circuito;

  • la red neuronal actúa como un controlador que decide qué tensión aplicar;

  • la función de costo mide si el capacitor llega al valor deseado y cuánta acción de control fue necesaria.

Esto ilustra bien la lógica FD + NN: la red propone acciones; la física diferenciable calcula sus consecuencias a lo largo del tiempo; backward() ajusta los pesos de la red a partir del objetivo final.

  1. Implementar una red pequeña que reciba V_c y V_target y devuelva V_in.

  2. Implementar simulate_rc_controlled.

  3. Entrenar la red para llevar el capacitor desde V0 = 0 hasta V_target = 4.

  4. Penalizar también señales de control excesivamente grandes.

  5. Graficar la evolución de V_c(t) y de V_in(t).

Sugerencia para la loss:

\[ \mathcal{L} = \frac{1}{T}\sum_t (V_c(t)-V_{\text{target}})^2 + \lambda \frac{1}{T}\sum_t V_{in}(t)^2. \]
class RCController(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(2, 32),
            nn.Tanh(),
            nn.Linear(32, 32),
            nn.Tanh(),
            nn.Linear(32, 1),
        )

    def forward(self, vc, v_target):
        # TODO: concatenar vc y v_target como entrada
        # TODO: devolver un escalar de control V_in
        raise NotImplementedError


def simulate_rc_controlled(V0, R, C, controller, v_target, dt, steps):
    vc = V0
    vcs = [vc]
    vins = []

    for _ in range(steps):
        # TODO: pedirle a la red la tension de entrada
        # TODO: avanzar la dinamica del capacitor
        # TODO: guardar Vc y Vin
        pass

    # TODO: devolver trayectorias completas
    raise NotImplementedError


V0 = torch.tensor(0.0)
R = torch.tensor(2.0)
C = torch.tensor(1.5)
v_target = torch.tensor(4.0)
dt = 0.05
steps = 120
lambda_u = 1e-3

controller = RCController()
optimizer = torch.optim.Adam(controller.parameters(), lr=1e-3)
history = []

for epoch in range(600):
    # TODO: simular el sistema controlado
    # TODO: definir loss de seguimiento + regularizacion del control
    # TODO: hacer backward y optimizer.step()
    pass

# TODO: graficar trayectoria de Vc y señal Vin