Alternativa a ML-Agents: integración de redes neuronales en un proyecto de Unity utilizando la API PyTorch C ++





Explicaré brevemente lo que sucederá en este artículo:



  • Le mostraré cómo usar la API de PyTorch C ++ para integrar una red neuronal en un proyecto en el motor Unity;
  • No describiré el proyecto en detalle, no importa para este artículo;
  • Utilizo un modelo de red neuronal listo para usar, transformando su rastreo en un binario que se cargará en tiempo de ejecución;
  • Demostraré que este enfoque facilita enormemente la implementación de proyectos complejos (por ejemplo, no hay problemas con la sincronización de los entornos Unity y Python).


Bienvenido al mundo real



Las técnicas de aprendizaje automático, incluidas las redes neuronales, siguen siendo muy cómodas en entornos experimentales y, a menudo, lanzar proyectos de este tipo en el mundo real es difícil. Hablaré un poco sobre estas dificultades, describiré las limitaciones sobre cómo salir de ellas y también daré una solución paso a paso al problema de integrar una red neuronal en un proyecto de Unity. 



En otras palabras, necesito convertir un proyecto de investigación en PyTorch en una solución lista para usar que pueda funcionar con el motor Unity en condiciones de combate.



Hay varias formas de integrar una red neuronal en Unity. Sugiero usar la API de C ++ para PyTorch (llamada libtorch) para crear una biblioteca compartida nativa que luego se puede conectar a Unity como un complemento. Existen otros enfoques (por ejemplo, usar ML-Agents ), que en ciertos casos pueden ser más simples y efectivos. Pero la ventaja de mi enfoque es que proporciona más flexibilidad y más potencia. 



Digamos que tiene un modelo exótico y solo quiere usar el código PyTorch existente (que fue escrito sin la intención de comunicarse con Unity); o su equipo está desarrollando su propio modelo y no quiere distraerse con pensamientos de Unity. En ambos casos, el código del modelo puede ser tan complejo como desee y utilizar todas las funciones de PyTorch. Y si de repente se trata de la integración, la API de C ++ entrará en juego y empaquetará todo en una biblioteca sin el más mínimo cambio en el código PyTorch original del modelo.



Entonces mi enfoque se reduce a cuatro pasos clave:



  1. Configurando el medio ambiente.
  2. Preparando una biblioteca nativa (C ++).
  3. Importación de funciones desde la conexión de biblioteca / complemento (Unity / C #). 
  4. Guardar / implementar el modelo.





IMPORTANTE: dado que hice el proyecto mientras estaba sentado en Linux, algunos comandos y configuraciones se basan en este sistema operativo; pero no creo que nada aquí deba depender demasiado de ella. Por lo tanto, es poco probable que la preparación de la biblioteca para Windows cause dificultades.



Configurando el medio ambiente



Antes de instalar libtorch, asegúrese de tener



  • CMake


Y si desea utilizar una GPU, necesita:





Pueden surgir dificultades con CUDA, porque el conductor, las bibliotecas y otros caquis deben ser amigos entre sí. Y debe enviar estas bibliotecas con su proyecto de Unity para que todo funcione desde el primer momento. Entonces esta es la parte más incómoda para mí. Si no planea usar GPU y CUDA, debe saberlo: los cálculos se ralentizarán entre 50 y 100 veces. E incluso si el usuario tiene una GPU bastante débil, es mejor con ella que sin ella. Incluso si su red neuronal se enciende con muy poca frecuencia, estos raros encendidos provocarán un retraso que molestará al usuario. Puede ser diferente en tu caso, pero ... ¿necesitas este riesgo?



Una vez que haya instalado el software anterior, es hora de descargar e instalar (localmente) libtorch. No es necesario instalarlo para todos los usuarios: simplemente puede colocarlo en el directorio de su proyecto y consultarlo cuando inicie CMake.



Preparando una biblioteca nativa



El siguiente paso es configurar CMake. Tomé el ejemplo de la documentación de PyTorch como base y lo cambié para que después de construir obtenemos la biblioteca, no el archivo ejecutable. Coloque este archivo en el directorio raíz de su proyecto de biblioteca nativa.



CMakeLists.txt


cmake_minimum_required(VERSION 3.0 FATAL_ERROR)

project(networks)

find_package(Torch REQUIRED)

set(CMAKE_CXX_FLAGS «${CMAKE_CXX_FLAGS} ${TORCH_CXX_FLAGS}»)

add_library(networks SHARED networks.cpp)

target_link_libraries(networks «${TORCH_LIBRARIES}»)

set_property(TARGET networks PROPERTY CXX_STANDARD 14)

if (MSVC)

	file(GLOB TORCH_DLLS «${TORCH_INSTALL_PREFIX}/lib/*.dll»)

	add_custom_command(TARGET networks

		POST_BUILD

		COMMAND ${CMAKE_COMMAND} -E copy_if_different

		${TORCH_DLLS}

		$<TARGET_FILE_DIR:example-app>)

endif (MSVC)
      
      





El código fuente de la biblioteca estará ubicado en networks.cpp



Este enfoque tiene otra característica interesante: todavía no tenemos que pensar en qué red neuronal queremos usar con Unity. La razón (adelantándome un poco) es que en cualquier momento podemos ejecutar la red en Python, obtener un rastro de ella y simplemente decirle a libtorch que "aplique este rastro a estas entradas". Por lo tanto, podemos decir que nuestra biblioteca nativa simplemente sirve como una especie de caja negra, trabajando con E / S.



Pero si desea complicar la tarea y, por ejemplo, implementar el entrenamiento de red directamente mientras se ejecuta el entorno de Unity, entonces debe escribir la arquitectura de red y el algoritmo de entrenamiento en C ++. Sin embargo, eso está fuera del alcance de este artículo, por lo que para obtener más información, lo remito a la sección relevante del repositorio de ejemplos de código y documentación de PyTorch . De todos modos, en network.cpp necesitamos definir una función externa para inicializar la red (arrancar desde disco) y una función externa que inicia la red con datos de entrada y devuelve resultados.







networks.cpp


#include <torch/script.h>

#include <vector>

#include <memory> 

extern «C»

{

// This is going to store the loaded network

torch::jit::script::Module network;
      
      





Para llamar a nuestras funciones de biblioteca directamente desde Unity, necesitamos pasar información sobre sus puntos de entrada. En Linux, uso __attribute __ ((visibilidad ("predeterminado"))) para esto. En Windows hay un especificador __declspec (dllexport) para esto , pero para ser honesto, no he probado si funciona allí . Entonces, comencemos con la función de cargar una traza de red neuronal desde el disco. El archivo está en una ruta relativa: está en la raíz del proyecto Unity, no en Assets / . Así que ten cuidado. También puede simplemente pasar el nombre de archivo de Unity.  






extern __attribute__((visibility(«default»))) void InitNetwork()

{
	network = torch::jit::load(«network_trace.pt»);

	network.to(at::kCUDA); // If we're doing this on GPU
}

      
      





Ahora pasemos a la función que alimenta la red con datos de entrada. Escribamos código C ++ que use punteros (administrados por Unity) para hacer un bucle de datos. En este ejemplo, supongo que mi red tiene entradas y salidas fijas, y evito que Unity cambie esto. Aquí, por ejemplo, tomaré el tensor {1,3,64,64} y el tensor {1,5,64,64} (por ejemplo, se necesita una red de este tipo para segmentar los píxeles de las imágenes RGB en 5 grupos) .



En general, deberá pasar información sobre la dimensión y la cantidad de datos para evitar desbordamientos de búfer.



Para convertir los datos al formato con el que trabaja libtorch, usamos la función torch :: from_blob... Toma una matriz de números de punto flotante y una descripción de tensor (con dimensiones) y devuelve el tensor generado.



Las redes neuronales pueden tomar múltiples argumentos de entrada (por ejemplo, call forward () toma x, y, z como entrada). Para manejar esto, todos los tensores de entrada se envuelven en un vector de la biblioteca de plantillas estándar torch :: jit :: IValue (incluso si solo hay un argumento).



Para obtener datos de un tensor, la forma más fácil es procesarlo elemento por elemento, pero si esto ralentiza la velocidad de procesamiento, puede usar Tensor :: accessor para optimizar el proceso de lectura de datos . Aunque personalmente no lo necesitaba.



Como resultado, se obtiene el siguiente código simple para mi red neuronal:



extern __attribute__((visibility(«default»))) void ApplyNetwork(float *data, float *output)

{

Tensor x = torch::from_blob(data, {1,3,64,64}).cuda();

std::vector<torch::jit::IValue> inputs;

inputs.push_back(x);

Tensor z = network.forward(inputs).toTensor();

for (int i=0;i<1*5*64*64;i++)

output[i] = z[0][i].item<float>();

}

}

      
      





Para compilar el código, siga las instrucciones de la documentación , cree un subdirectorio de compilación y ejecute los siguientes comandos:



cmake -DCMAKE_PREFIX_PATH=/absolute/path/to/libtorch <strong>..</strong>

cmake --build <strong>.</strong> --config Release

      
      





Si todo va bien, se generarán archivos libnetworks.so o networks.dll que puede colocar en Activos / Complementos / de su proyecto de Unity.



Conectando el complemento a Unity



Para importar funciones de la biblioteca, use DllImport . La primera función que necesitamos es InitNetwork (). Al conectar un complemento, Unity lo llamará:



using System.Runtime.InteropServices;

public class Startup : MonoBehaviour

{

...

[DllImport(«networks»)]

private static extern void InitNetwork();

void Start()

{

...

InitNetwork();

...

}

}

      
      





Para que el motor Unity (C #) pueda comunicarse con la biblioteca (C ++), le encomendaré todo el trabajo de administración de memoria:



  • Asignaré memoria para matrices del tamaño requerido en el lado de Unity;
  • pasar la dirección del primer elemento de la matriz a la función ApplyNetwork (también debe importarse antes de eso);
  • simplemente deje que la aritmética de direcciones de C ++ acceda a esa memoria cuando se reciban o envíen datos.


En el código de la biblioteca (C ++), tengo que evitar cualquier asignación o desasignación de memoria. Por otro lado, si paso la dirección del primer elemento de la matriz de Unity a la función ApplyNetwork, tengo que guardar este puntero (y la porción de memoria correspondiente) hasta que la red neuronal termine de procesar los datos.



Afortunadamente, mi biblioteca nativa hace el trabajo simple de destilar los datos, por lo que fue bastante fácil realizar un seguimiento. Pero si desea paralelizar los procesos para que la red neuronal aprenda y procese datos simultáneamente para el usuario, tendrá que buscar algún tipo de solución.



[DllImport(«networks»)]

private static extern void ApplyNetwork(ref float data, ref float output);

void SomeFunction() {

float[] input = new float[1*3*64*64];

float[] output = new float[1*5*64*64];

// Load input with whatever data you want

...

ApplyNetwork(ref input[0], ref output[0]);

// Do whatever you want with the output

...

}

      
      





Guardando el modelo



El artículo está llegando a su fin y todavía discutimos qué red neuronal elegí para mi proyecto. Es una red neuronal convolucional simple que se puede utilizar para segmentar imágenes. No incluí la recopilación de datos y el entrenamiento en el modelo: mi tarea es hablar sobre la integración con Unity y no sobre los problemas con el rastreo de redes neuronales complejas. No me culpes.



Si tiene curiosidad, aquí hay un ejemplo bueno y complejo que describe algunos casos especiales y problemas potenciales. Uno de los principales problemas es que el seguimiento no funciona correctamente para todos los tipos de datos. La documentación explica cómo resolver el problema mediante anotaciones y compilación explícita.



Así es como podría verse el código Python para nuestro modelo simple:



import torch

import torch.nn as nn

import torch.nn.functional as F

class Net(nn.Module):

def __init__(self):

super().__init__()

self.c1 = nn.Conv2d(3,64,5,padding=2)

self.c2 = nn.Conv2d(64,5,5,padding=2)

def forward(self, x): z = F.leaky_relu(self.c1(x)) z = F.log_softmax(self.c2(z), dim=1)

return

   , , , ,  .

 ()       :

network = Net().cuda()

example = torch.rand(1, 3, 32, 32).cuda()

traced_network = torch.jit.trace(network, example)

traced_network.save(«network_trace.pt»)

      
      





Ampliando el modelo



Creamos una biblioteca estática, pero esto no es suficiente para la implementación: es necesario incluir bibliotecas adicionales en el proyecto. Desafortunadamente, no estoy 100% seguro de qué bibliotecas deben incluirse. Elegí libtorch, libc10, libc10_cuda, libnvToolsExt y libcudart . En total, agregan 2 GB al tamaño del proyecto original. 



LibTorch frente a agentes ML



Creo que para muchos proyectos, especialmente en investigación y creación de prototipos, ML-Agents, un complemento creado específicamente para Unity, realmente vale la pena elegir. Pero cuando los proyectos se vuelven más complejos, debe ir a lo seguro, en caso de que algo salga mal. Y esto sucede con bastante frecuencia ...



Hace un par de semanas, usé ML-Agents para comunicarme entre un juego de demostración en Unity y un par de redes neuronales escritas en Python. Dependiendo de la lógica del juego, Unity llamaría a una de estas redes con diferentes conjuntos de datos.



Tuve que profundizar en la API de Python para ML-Agents. Algunas de las operaciones que usé en mis redes neuronales, como 1d fold y transpose, no fueron compatibles con Barracuda (esta es la biblioteca de rastreo que usan actualmente ML-Agents).



El problema con el que me encontré fue que ML-Agents recopila "solicitudes" de los agentes durante un cierto intervalo de tiempo y luego las envía para su evaluación, por ejemplo, a un cuaderno de Jupyter. Sin embargo, algunas de mis redes neuronales dependían de la salida de mis otras redes. Y para obtener una estimación de toda la cadena de mis redes neuronales, tendría que esperar un poco, obtener el resultado, hacer otra solicitud, esperar, obtener el resultado, etc., cada vez que realice una solicitud. Además, el orden en el que se pusieron en funcionamiento estas redes no dependía en absoluto de las aportaciones del usuario. Esto significaba que no podía simplemente ejecutar redes neuronales de forma secuencial. 



Además, en algunos casos, la cantidad de datos que necesitaba enviar tenía que variar. Y ML-Agents está más diseñado para una dimensión fija para cada agente (parece que se puede cambiar sobre la marcha, pero soy escéptico sobre esto).



Podría hacer algo como calcular la secuencia de llamadas a redes neuronales a pedido, enviando la entrada apropiada a la API de Python. Pero debido a esto, mi código, tanto del lado de Unity como del lado de Python, se volvería demasiado complejo o incluso redundante. Por lo tanto, decidí estudiar el enfoque usando libtorch, y estaba bien.



Si antes alguien me hubiera pedido que construyera un modelo predictivo GPT-2 o MAML en un proyecto de Unity, le recomendaría que intentara prescindir de él. Implementar una tarea de este tipo con ML-Agents es demasiado complicado. Pero ahora puedo encontrar o desarrollar cualquier modelo con PyTorch y luego envolverlo en una biblioteca nativa que se conecta a Unity como un complemento normal.






Los servidores en la nube de Macleod son rápidos y seguros.



Regístrese usando el enlace de arriba o haciendo clic en el banner y obtenga un 10% de descuento durante el primer mes de alquiler de un servidor de cualquier configuración.






All Articles