Hasta ahora, todos torch
Los casos de uso que hemos discutido aquí han sido en aprendizaje profundo. Sin embargo, su función de diferenciación automática es útil en otras áreas. Un ejemplo destacado es la optimización numérica: podemos utilizar torch
para encontrar el mínimo de una función.
De hecho, la minimización de funciones es exactamente ¿Qué sucede al entrenar una pink neuronal? Pero allí, la función en cuestión normalmente es demasiado compleja como para siquiera imaginar encontrar sus mínimos analíticamente. La optimización numérica tiene como objetivo desarrollar las herramientas para manejar precisamente esta complejidad. Sin embargo, para ello parte de funciones mucho menos profundamente compuestas. En cambio, están hechos a mano para plantear desafíos específicos.
Esta publicación es una primera introducción a la optimización numérica con torch
. Las conclusiones centrales son la existencia y utilidad de su optimizador L-BFGS, así como el impacto de ejecutar L-BFGS con búsqueda de línea. Como complemento divertido, mostramos un ejemplo de optimización restringida, donde una restricción se aplica mediante una función de penalización cuadrática.
Para calentar, tomamos un desvío, minimizando una función “nosotros mismos” usando nada más que tensores. Sin embargo, esto resultará relevante más adelante, ya que el proceso common seguirá siendo el mismo. Todos los cambios estarán relacionados con la integración de optimizer
s y sus capacidades.
Minimización de funciones, enfoque DYI
Para ver cómo podemos minimizar una función “a mano”, probemos el icónico función de rosenbrock. Esta es una función con dos variables:
[
f(x_1, x_2) = (a – x_1)^2 + b * (x_2 – x_1^2)^2
]
con (a) y (b) Los parámetros configurables a menudo se establecen en 1 y 5, respectivamente.
En R:
Su mínimo se sitúa en (1,1), dentro de un estrecho valle rodeado de acantilados de vertiginosa pendiente:

Figura 1: Función Rosenbrock.
Nuestro objetivo y estrategia son los siguientes.
queremos encontrar los valores (x_1) y (x_2) para el cual la función alcanza su mínimo. Tenemos que empezar por alguna parte; y desde cualquier lugar del gráfico que nos lleve, seguimos el negativo del gradiente “hacia abajo”, descendiendo a regiones de valor de función consecutivamente más pequeño.
Concretamente, en cada iteración, tomamos el precise ((x1,x2)) punto, calcule el valor de la función así como el gradiente, y reste una fracción de este último para llegar a un nuevo ((x1,x2)) candidato. Este proceso continúa hasta que alcanzamos el mínimo (el gradiente es cero) o la mejora está por debajo de un umbral elegido.
Aquí está el código correspondiente. Sin motivos especiales, comenzamos en (-1,1)
. La tasa de aprendizaje (la fracción del gradiente a restar) necesita algo de experimentación. (Pruebe 0,1 y 0,001 para ver su impacto).
num_iterations <- 1000
# fraction of the gradient to subtract
lr <- 0.01
# operate enter (x1,x2)
# that is the tensor w.r.t. which we'll have torch compute the gradient
x_star <- torch_tensor(c(-1, 1), requires_grad = TRUE)
for (i in 1:num_iterations) {
if (i %% 100 == 0) cat("Iteration: ", i, "n")
# name operate
worth <- rosenbrock(x_star)
if (i %% 100 == 0) cat("Worth is: ", as.numeric(worth), "n")
# compute gradient of worth w.r.t. params
worth$backward()
if (i %% 100 == 0) cat("Gradient is: ", as.matrix(x_star$grad), "nn")
# guide replace
with_no_grad({
x_star$sub_(lr * x_star$grad)
x_star$grad$zero_()
})
}
Iteration: 100
Worth is: 0.3502924
Gradient is: -0.667685 -0.5771312
Iteration: 200
Worth is: 0.07398106
Gradient is: -0.1603189 -0.2532476
...
...
Iteration: 900
Worth is: 0.0001532408
Gradient is: -0.004811743 -0.009894371
Iteration: 1000
Worth is: 6.962555e-05
Gradient is: -0.003222887 -0.006653666
Si bien esto funciona, realmente sirve para ilustrar el principio. Con torch
Al proporcionar una serie de algoritmos de optimización probados, no es necesario que calculemos manualmente el candidato. (mathbf{x}) valores.
Minimización de funciones con torch
optimizadores
En lugar de eso, dejamos que un torch
optimizador actualiza el candidato (mathbf{x}) para nosotros. Habitualmente, nuestro primer intento es Adán.
Adán
Con Adam, la optimización avanza mucho más rápido. A decir verdad, elegir una buena tasa de aprendizaje aún requiere experimentación no despreciable. (Pruebe la tasa de aprendizaje predeterminada, 0,001, para comparar).
num_iterations <- 100
x_star <- torch_tensor(c(-1, 1), requires_grad = TRUE)
lr <- 1
optimizer <- optim_adam(x_star, lr)
for (i in 1:num_iterations) {
if (i %% 10 == 0) cat("Iteration: ", i, "n")
optimizer$zero_grad()
worth <- rosenbrock(x_star)
if (i %% 10 == 0) cat("Worth is: ", as.numeric(worth), "n")
worth$backward()
optimizer$step()
if (i %% 10 == 0) cat("Gradient is: ", as.matrix(x_star$grad), "nn")
}
Iteration: 10
Worth is: 0.8559565
Gradient is: -1.732036 -0.5898831
Iteration: 20
Worth is: 0.1282992
Gradient is: -3.22681 1.577383
...
...
Iteration: 90
Worth is: 4.003079e-05
Gradient is: -0.05383469 0.02346456
Iteration: 100
Worth is: 6.937736e-05
Gradient is: -0.003240437 -0.006630421
Nos tomó alrededor de cien iteraciones llegar a un valor decente. Esto es mucho más rápido que el método guide anterior, pero aún así es bastante. Afortunadamente, es posible realizar más mejoras.
L-BFGS
entre los muchos torch
optimizadores comúnmente utilizados en el aprendizaje profundo (Adam, AdamW, RMSprop…), hay un “forastero”, mucho más conocido en la optimización numérica clásica que en el espacio de las redes neuronales: L-BFGS, también conocido como BFGS de memoria limitadauna implementación optimizada para memoria del Algoritmo de optimización Broyden-Fletcher-Goldfarb-Shanno (BFGS).
BFGS es quizás el más utilizado entre los llamados algoritmos de optimización de segundo orden Quasi-Newton. A diferencia de la familia de algoritmos de primer orden que, al decidir la dirección de descenso, utilizan únicamente información de gradiente, los algoritmos de segundo orden también tienen en cuenta la información de curvatura. Con ese fin, los métodos exactos de Newton en realidad calculan el hessiano (una operación costosa), mientras que los métodos cuasi-Newton evitan ese costo y, en cambio, recurren a una aproximación iterativa.
Al observar los contornos de la función Rosenbrock, con su valle estrecho y prolongado, no es difícil imaginar que la información de curvatura podría marcar la diferencia. Y, como verás en un segundo, realmente es así. Antes, sin embargo, una nota sobre el código. Cuando se utiliza L-BFGS, es necesario envolver tanto la llamada a la función como la evaluación del gradiente en un cierre (calc_loss()
en el siguiente fragmento), para que se puedan invocar varias veces por iteración. Puedes convencerte de que el cierre, de hecho, se ingresa repetidamente, inspeccionando la salida de este fragmento de código:
num_iterations <- 3
x_star <- torch_tensor(c(-1, 1), requires_grad = TRUE)
optimizer <- optim_lbfgs(x_star)
calc_loss <- operate() {
optimizer$zero_grad()
worth <- rosenbrock(x_star)
cat("Worth is: ", as.numeric(worth), "n")
worth$backward()
cat("Gradient is: ", as.matrix(x_star$grad), "nn")
worth
}
for (i in 1:num_iterations) {
cat("Iteration: ", i, "n")
optimizer$step(calc_loss)
}
Iteration: 1
Worth is: 4
Gradient is: -4 0
Worth is: 6
Gradient is: -2 10
...
...
Worth is: 0.04880721
Gradient is: -0.262119 -0.1132655
Worth is: 0.0302862
Gradient is: 1.293824 -0.7403332
Iteration: 2
Worth is: 0.01697086
Gradient is: 0.3468466 -0.3173429
Worth is: 0.01124081
Gradient is: 0.2420997 -0.2347881
...
...
Worth is: 1.111701e-09
Gradient is: 0.0002865837 -0.0001251698
Worth is: 4.547474e-12
Gradient is: -1.907349e-05 9.536743e-06
Iteration: 3
Worth is: 4.547474e-12
Gradient is: -1.907349e-05 9.536743e-06
Aunque ejecutamos el algoritmo durante tres iteraciones, el valor óptimo realmente se alcanza después de dos. Al ver lo bien que funcionó, probamos L-BFGS en una función más difícil, llamada florpor razones bastante evidentes.
(Aún) más diversión con L-BFGS
Aquí está el flor función. Matemáticamente, su mínimo está cerca (0,0)
pero técnicamente la función en sí no está definida en (0,0)
desde el atan2
utilizado en la función no está definido allí.
a <- 1
b <- 1
c <- 4
flower <- operate(x) {
a * torch_norm(x) + b * torch_sin(c * torch_atan2(x[2], x[1]))
}

Figura 2: Función de la flor.
Ejecutamos el mismo código que el anterior, comenzando desde (20,20)
esta vez.
num_iterations <- 3
x_star <- torch_tensor(c(20, 0), requires_grad = TRUE)
optimizer <- optim_lbfgs(x_star)
calc_loss <- operate() {
optimizer$zero_grad()
worth <- flower(x_star)
cat("Worth is: ", as.numeric(worth), "n")
worth$backward()
cat("Gradient is: ", as.matrix(x_star$grad), "n")
cat("X is: ", as.matrix(x_star), "nn")
worth
}
for (i in 1:num_iterations) {
cat("Iteration: ", i, "n")
optimizer$step(calc_loss)
}
Iteration: 1
Worth is: 28.28427
Gradient is: 0.8071069 0.6071068
X is: 20 20
...
...
Worth is: 19.33546
Gradient is: 0.8100872 0.6188223
X is: 12.957 14.68274
...
...
Worth is: 18.29546
Gradient is: 0.8096464 0.622064
X is: 12.14691 14.06392
...
...
Worth is: 9.853705
Gradient is: 0.7546976 0.7025688
X is: 5.763702 8.895616
Worth is: 2635.866
Gradient is: -0.7407354 -0.6717985
X is: -1949.697 -1773.551
Iteration: 2
Worth is: 1333.113
Gradient is: -0.7413024 -0.6711776
X is: -985.4553 -897.5367
Worth is: 30.16862
Gradient is: -0.7903821 -0.6266789
X is: -21.02814 -21.72296
Worth is: 1281.39
Gradient is: 0.7544561 0.6563575
X is: 964.0121 843.7817
Worth is: 628.1306
Gradient is: 0.7616636 0.6480014
X is: 475.7051 409.7372
Worth is: 4965690
Gradient is: -0.7493951 -0.662123
X is: -3721262 -3287901
Worth is: 2482306
Gradient is: -0.7503822 -0.6610042
X is: -1862675 -1640817
Worth is: 8.61863e+11
Gradient is: 0.7486113 0.6630091
X is: 645200412672 571423064064
Worth is: 430929412096
Gradient is: 0.7487153 0.6628917
X is: 322643460096 285659529216
Worth is: Inf
Gradient is: 0 0
X is: -2.826342e+19 -2.503904e+19
Iteration: 3
Worth is: Inf
Gradient is: 0 0
X is: -2.826342e+19 -2.503904e+19
Esto ha tenido menos éxito. Al principio, la pérdida disminuye considerablemente, pero de repente, la estimación se excede drásticamente y sigue rebotando entre el espacio exterior negativo y positivo para siempre.
Por suerte, hay algo que podemos hacer.
L-BFGS con búsqueda de líneas
Tomado de forma aislada, lo que hace un método Quasi-Newton como el L-BFGS es determinar la mejor dirección de descenso. Sin embargo, como acabamos de ver, una buena dirección no basta. Con la función de la flor, dondequiera que estemos, el camino óptimo conduce al desastre si permanecemos en él el tiempo suficiente. Por lo tanto, necesitamos un algoritmo que evalúe cuidadosamente no sólo adónde ir, sino también hasta dónde ir.
Por esta razón, las implementaciones L-BFGS comúnmente incorporan búsqueda de líneaes decir, un conjunto de reglas que indican si la longitud de un paso propuesta es buena o si debe mejorarse.
Específicamente, torch
El optimizador L-BFGS de implementa el Fuertes condiciones de Wolfe. Volvemos a ejecutar el código anterior, cambiando solo dos líneas. Lo más importante es aquel en el que se crea una instancia del optimizador:
optimizer <- optim_lbfgs(x_star, line_search_fn = "strong_wolfe")
Y en segundo lugar, esta vez descubrí que después de la tercera iteración, la pérdida continuó disminuyendo por un tiempo, así que lo dejé funcionar durante cinco iteraciones. Aquí está el resultado:
Iteration: 1
...
...
Worth is: -0.8838741
Gradient is: 3.742207 7.521572
X is: 0.09035123 -0.03220009
Worth is: -0.928809
Gradient is: 1.464702 0.9466625
X is: 0.06564617 -0.026706
Iteration: 2
...
...
Worth is: -0.9991404
Gradient is: 39.28394 93.40318
X is: 0.0006493925 -0.0002656128
Worth is: -0.9992246
Gradient is: 6.372203 12.79636
X is: 0.0007130796 -0.0002947929
Iteration: 3
...
...
Worth is: -0.9997789
Gradient is: 3.565234 5.995832
X is: 0.0002042478 -8.457939e-05
Worth is: -0.9998025
Gradient is: -4.614189 -13.74602
X is: 0.0001822711 -7.553725e-05
Iteration: 4
...
...
Worth is: -0.9999917
Gradient is: -382.3041 -921.4625
X is: -6.320081e-06 2.614706e-06
Worth is: -0.9999923
Gradient is: -134.0946 -321.2681
X is: -6.921942e-06 2.865841e-06
Iteration: 5
...
...
Worth is: -0.9999999
Gradient is: -3446.911 -8320.007
X is: -7.267168e-08 3.009783e-08
Worth is: -0.9999999
Gradient is: -3419.361 -8253.501
X is: -7.404627e-08 3.066708e-08
Todavía no es perfecto, pero es mucho mejor.
Finalmente, vayamos un paso más allá. ¿Podemos usar? torch
para optimización restringida?
Penalización cuadrática por optimización restringida
En la optimización restringida, todavía buscamos un mínimo, pero ese mínimo no puede residir en cualquier lugar: su ubicación debe cumplir una serie de condiciones adicionales. En la jerga de optimización, tiene que ser factible.
Para ilustrar, nos quedamos con la función de flor, pero agregamos una restricción: (mathbf{x}) tiene que estar fuera de un círculo de radio (sqrt(2))centrado en el origen. Formalmente, esto produce la restricción de desigualdad.
[
2 – {x_1}^2 – {x_2}^2 <= 0
]
Una manera de minimizar flor y, sin embargo, al mismo tiempo, respetar la restricción es utilizar una función de penalización. Con los métodos de penalización, el valor que se debe minimizar es la suma de dos cosas: la salida de la función objetivo y una penalización que refleja una posible violación de la restricción. uso de un cuadrático penapor ejemplo, da como resultado sumar un múltiplo del cuadrado de la salida de la función de restricción:
# x^2 + y^2 >= 2
# 2 - x^2 - y^2 <= 0
constraint <- operate(x) 2 - torch_square(torch_norm(x))
# quadratic penalty
penalty <- operate(x) torch_square(torch_max(constraint(x), different = 0))
A priori, no podemos saber qué tan grande debe ser ese múltiplo para imponer la restricción. Por tanto, la optimización se realiza de forma iterativa. Empezamos con un pequeño multiplicador, (1)digamos, y auméntelo mientras se siga violando la restricción:
penalty_method <- operate(f, p, x, k_max, rho = 1, gamma = 2, num_iterations = 1) {
for (okay in 1:k_max) {
cat("Beginning step: ", okay, ", rho = ", rho, "n")
decrease(f, p, x, rho, num_iterations)
cat("Worth: ", as.numeric(f(x)), "n")
cat("X: ", as.matrix(x), "n")
current_penalty <- as.numeric(p(x))
cat("Penalty: ", current_penalty, "n")
if (current_penalty == 0) break
rho <- rho * gamma
}
}
decrease()
llamado desde penalty_method()
sigue los procedimientos habituales, pero ahora minimiza la suma de las salidas de la función de penalización objetivo y ponderada:
decrease <- operate(f, p, x, rho, num_iterations) {
calc_loss <- operate() {
optimizer$zero_grad()
worth <- f(x) + rho * p(x)
worth$backward()
worth
}
for (i in 1:num_iterations) {
cat("Iteration: ", i, "n")
optimizer$step(calc_loss)
}
}
Esta vez, partimos de un valor de baja pérdida objetivo, pero inviable. Con otro cambio más al L-BFGS predeterminado (es decir, una disminución en la tolerancia), vemos que el algoritmo sale exitosamente después de veintidós iteraciones, en el punto (0.5411692,1.306563)
.
x_star <- torch_tensor(c(0.5, 0.5), requires_grad = TRUE)
optimizer <- optim_lbfgs(x_star, line_search_fn = "strong_wolfe", tolerance_change = 1e-20)
penalty_method(flower, penalty, x_star, k_max = 30)
Beginning step: 1 , rho = 1
Iteration: 1
Worth: 0.3469974
X: 0.5154735 1.244463
Penalty: 0.03444662
Beginning step: 2 , rho = 2
Iteration: 1
Worth: 0.3818618
X: 0.5288152 1.276674
Penalty: 0.008182613
Beginning step: 3 , rho = 4
Iteration: 1
Worth: 0.3983252
X: 0.5351116 1.291886
Penalty: 0.001996888
...
...
Beginning step: 20 , rho = 524288
Iteration: 1
Worth: 0.4142133
X: 0.5411959 1.306563
Penalty: 3.552714e-13
Beginning step: 21 , rho = 1048576
Iteration: 1
Worth: 0.4142134
X: 0.5411956 1.306563
Penalty: 1.278977e-13
Beginning step: 22 , rho = 2097152
Iteration: 1
Worth: 0.4142135
X: 0.5411962 1.306563
Penalty: 0
Conclusión
En resumen, hemos tenido una primera impresión de la eficacia de torch
El optimizador L-BFGS, especialmente cuando se usa con la búsqueda de líneas Robust-Wolfe. De hecho, en la optimización numérica (a diferencia del aprendizaje profundo, donde la velocidad computacional es un problema mucho mayor) casi nunca hay una razón para no utilice L-BFGS con búsqueda de líneas.
Luego vislumbramos cómo realizar una optimización restringida, una tarea que surge en muchas aplicaciones del mundo actual. En ese sentido, esta publicación parece más un comienzo que un steadiness. Hay mucho que explorar, desde el ajuste del método common: ¿cuándo se adapta bien el L-BFGS a un problema? – desde la eficacia computacional hasta la aplicabilidad a diferentes especies de redes neuronales. No hace falta decir que si esto te inspira a realizar tus propios experimentos y/o si utilizas L-BFGS en tus propios proyectos, ¡nos encantaría escuchar tus comentarios!
¡Gracias por leer!
Apéndice
Código de trazado de la función Rosenbrock
library(tidyverse)
a <- 1
b <- 5
rosenbrock <- operate(x) {
x1 <- x[1]
x2 <- x[2]
(a - x1)^2 + b * (x2 - x1^2)^2
}
df <- expand_grid(x1 = seq(-2, 2, by = 0.01), x2 = seq(-2, 2, by = 0.01)) %>%
rowwise() %>%
mutate(x3 = rosenbrock(c(x1, x2))) %>%
ungroup()
ggplot(knowledge = df,
aes(x = x1,
y = x2,
z = x3)) +
geom_contour_filled(breaks = as.numeric(torch_logspace(-3, 3, steps = 50)),
present.legend = FALSE) +
theme_minimal() +
scale_fill_viridis_d(route = -1) +
theme(facet.ratio = 1)
Código de trazado de función de flor
a <- 1
b <- 1
c <- 4
flower <- operate(x) {
a * torch_norm(x) + b * torch_sin(c * torch_atan2(x[2], x[1]))
}
df <- expand_grid(x = seq(-3, 3, by = 0.05), y = seq(-3, 3, by = 0.05)) %>%
rowwise() %>%
mutate(z = flower(torch_tensor(c(x, y))) %>% as.numeric()) %>%
ungroup()
ggplot(knowledge = df,
aes(x = x,
y = y,
z = z)) +
geom_contour_filled(present.legend = FALSE) +
theme_minimal() +
scale_fill_viridis_d(route = -1) +
theme(facet.ratio = 1)
Foto por Michael Trimble en desempaquetar