24.8 C
Colombia
lunes, julio 7, 2025

Posit AI Weblog: antorcha para la optimización


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 optimizers 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:

library(torch)

a <- 1
b <- 5

rosenbrock <- operate(x) {
  x1 <- x[1]
  x2 <- x[2]
  (a - x1)^2 + b * (x2 - x1^2)^2
}

Su mínimo se sitúa en (1,1), dentro de un estrecho valle rodeado de acantilados de vertiginosa pendiente:


Función Rosenbrock.

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]))
}

Función floral.

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.

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, torchEl 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 torchEl 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

Related Articles

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Latest Articles