LLM 101 Introducción

Vamos a hacer una serie de preguntas rápidas para ayudar a los alumnos a empezar a trabajar con modelos grandes del lenguaje o LLM (Large Language Model)

¿Que es un modelo grande del lenguaje o LLM del inglés Large Language Model?

Es una red neuronal profunda (con muchas capas) diseñadas para procesar lenguaje natural (NLP del inglés natural language processing). Esto es, entender y generar lenguaje natural escrito. Como todas las redes neuronales, hay que entrenarlas. Una LLM se entrena con mucho texto y “aprende” patrones complejos del lenguaje.

¿Por qué se han puesto de moda?

Hace unos años, unas arquitecturas llamadas Transformer y sobre todo, el entrenamiento masivo con toda la información que hay disponible en Internet ha hecho que los LLM tengan unas prestaciones increíbles en tareas concretas, mas allá de las que originalmente fueron diseñadas. chatGPT (Generative Pre-trained Transformer) de OpenAI y BERT (Bidrectional Encoder Representations form Transformer) de google son las arquitecturas mas famosas. Una descripción de la arquitectura transformer puedes verla aquí

¿Pero un LLM es un programa que ejecuto en mi ordenador?

No exactamente, es un archivo binario que contiene la configuración de la red neuronal después del entrenamiento, esto es, los “pesos” de cada neurona, de cada capa, calculados en el proceso de aprendizaje.
Los modelos pueden tener distintos formatos, generalmente indicados en el nombre. Aunque no hay un estándar de nombrado, se detectan patrones en el nombrado de los modelos. Por ejemplo, SynthIA-7B-v2.0-16k-AWQ es un modelo entrenado con 7B, siete billones de parámetros, en su versión 2.0 y con un tamaño de contexto de 16K. La AWQ indica el formato y la precisión en el cual se han almacenado los “pesos” de cada neurona.

¿Qué es el tamaño del contexto?

Una cosa es el modelo LLM y otra cosa son sus parámetros. El tamaño del contexto es el número máximo de tokens (palabras) que le puedes pasar al LLM. Lo define la arquitectura del LLM y generalmente, a mayor tamaño del contexto, mejor se puede especificar la tarea que le pides al LLM por lo que generará cosas mas precisas. También es cierto que generalmente, implica mas memoria. Es el prompt que le metes a chatGPT para que te responda.

Vale, pero ¿Cómo ejecuto un LLM en mi máquina?

Bien, tienes que cargar el modelo binario en memoria para poder introducirles contexto (el famoso prompt) y obtener lo que genera. Los modelos pueden ser muy grandes por lo que los requisitos hardware son exigentes. Dicho esto, hay librerías en varios lenguajes de programación que te permiten cargar en memoria e interactuar con LLMs mediante un sencillo programa.
Uno de los mejores recursos para trabajar en este campo y en IA en general es la web https:/huggingface.co , que tiene librerías, modelos, datasets, papers, etc. la guía de uso de esta página es un buen primer comienzo.

¿Me puedes poner un ejemplo?

El código python de mas abajo carga un modelo un modelo LLM de código abierto modesto, que se baja de la web https://huggingface.co/ e interacciona con él introduciéndoles prompts (el contexto que hablábamos antes) y obteniendo las respuestas. Realmente trata de predecir la siguiente palabra al prompt.

import time
import torch
import transformers
import accelerate

from transformers import AutoTokenizer, AutoModelForCausalLM

print("Loading model...")
print("transformer version: ", transformers.__version__)
print("accelerate version: ", accelerate.__version__)
timeStart = time.time()
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print("Using device:", device)

hugginfacemodelname = "openchat/openchat-3.5-1210"
tokenizer = AutoTokenizer.from_pretrained(hugginfacemodelname)
model = AutoModelForCausalLM.from_pretrained(
    hugginfacemodelname,
    torch_dtype=torch.bfloat16,
    low_cpu_mem_usage=True,
).to(device)

print("Load model time: ", -timeStart + time.time())

while True:
    input_str = input("Enter: ")
    input_token_length = input("Enter length: ")
    if input_str == "exit":
        break
    timeStart = time.time()
    inputs = tokenizer.encode(input_str, return_tensors="pt").to(device)
    outputs = model.generate(
        inputs,
        max_new_tokens=int(input_token_length),
    )
    output_str = tokenizer.decode(outputs[0])
    print(output_str)
    print("Time taken: ", -timeStart + time.time())

El código utiliza la librería python transformers y torch. En primer lugar se indica el modelo LLM a cargar, openchat/openchat-3.5-1210 y se genera el tokenizer que no es sino una herramienta que convierte el texto en tokens (palabras). Para el modelo que hemos especificado, la función “from_pretained” busca y carga el tokenizador correspondiente. Si se lo tiene que bajar, esto puede tardar. A continuación, con AutoModelForCausalLM.from_pretained.., se carga el modelo de lenguaje causal, un tipo de modelo que predice la siguiente palabra en una secuencia.

¿Hay mas tipos de modelos?

Si, en esa librería hay modelos específicos para pregunta-respuesta, para clasificación de imágenes, multimodales, etc. al cual le tienes que indicar qué modelo quieres entrenar.

¿El prompt o la entrada del LLM cómo se articula?

Asociado al concepto de LLM aparece el concepto de prompt engineering o ingeniería de prompts. Es importante destacar que un buen prompt o cadena de prompts puede mejorar mucho la salida de una determinada LLM en una tarea específica. En los últimos años aparece una gran cantidad de métodos para elaborar prompts:
Referencias: https://dl.acm.org/doi/pdf/10.1145/3560815

¿ Puedo entrenar un LLM en lo que yo quiera?

En principio si. Entrenar un LLM desde cero requiere de muchos recursos. La mayoría de los ingenieros hacen un proceso denominado finetunning que no es otra cosa que coger un modelo LLM ya entrado de forma genérica y aplicarles un proceso de entrenamiento con un conjunto de datos específico de la tarea donde quiero usar mi LLM. El resultado es un modelo LLM que tiene el conocimiento general y se ha especializado mediante lo que ha aprendido con los patrones del dataset con el cual has hecho el finetunning. Un ejemplo muy bueno que aparece en los vídeos de formación de google relacionados con LLM, es ver el finetunning similar al proceso de enseñarle a una mascota, por ejemplo un perro, una serie de tareas específicas (buscar droga, personas, órdenes, etc.). Tienes el perro, siendo en este simil, la LLM generalista, y mediante un proceso de entrenamiento (finetunning), obtienes una LLM que es mejor que la generalista en un proceso específico.

Otra técnica muy adecuada es el Retrieval Augmented Generation o RAG, que no es sino mejorar las respuestas de LLM en un dominio específico proporcionándole información específica de ese dominio. Lo que se hace es recuperar información relevante (de fuentes internas o externas) para la pregunta e introducirla en el contexto/prompt junto con la pregunta para obtener mejores respuestas. Realmente todo este proceso de recuperación lo que hace es mejorar el prompt.

Finetunning

En este enlace podemos ver los principios básicos de finetunning:
hay tres aproximaciones al finetuning

  1. Self-supervised learning
  2. Supervised learning
  3. Reinforcement learning

¿Por qué ocupa tanto una LLM?

Una red neuronal almacena pesos calculados en el proceso de entrenamiento, como toda
variable, esos pesos pueden almacenarse en diverso formatos (punto flotante vs entero) y con determinada precisión (64,32,16 bits). La precisión es importante por que es lo que almacena los patrones aprendidos.

¿Qué puedo hacer para reducir el tamaño de una LLM?

Hay un proceso, denominado cuantización, que pasa de una determinada precisión a otra. Si pasas de una precisión en punto flotante de 16 bits a un entero de 8 bits, esto hace que el modelo ocupe menos en el disco duro a costa de perder información/patrones. Es decir, el modelo es algo peor. Hay dos tipos de cuantización, simétrica y asimétrica Mas info: https://myscale.com/blog/es/quantization-for-llms-finetuning/

En el siguiente ejemplo podemos ver un ejemplo básico de finetunning para evaluar sentimiento en comentarios de películas. El ejemplo es muy sencillo pero completo, en primer lugar se evalúa con un pequeño testbench la LLM escogida, una de las mas sencillas, se entrena con un dataset de comentarios etiquetados y se vuelve a evaluar para ver si ha “aprendido”. https://towardsdatascience.com/fine-tuning-large-language-models-llms-23473d763b91

¿Cómo evaluamos un LLM?

Cómo sabemos si una LLM es buena o malo y qué podemos probar para ver si hemos mejorado. Bueno, aquí entra en juego la evaluación de las redes neuronales en general. Es decir, evaluar las prestaciones en base a una tarea específica pasa inherentemente por poder evaluar cómo de bueno son los resultados obtenidos ante prompts especificos.

Dos paradigmas populares, para la evaluación genérica de LLMs:

  1. El tradicional NLP Benchmark approach usando, por ejemplo, MosaicML’s Eval Gauntlet.
  2. Otra técnica muy utilizada es , precisamente, utilizar una LLM como juez.

¿Qué LLM es mejor?

Con la aparición de múltiples LLMs han aparecido numerosas métricas y rankings en diferentes testbench. Aquí puedes ver el ranking de hugging face: link

Herramientas y recursos

https://pinokio.computer/ una WEBUI para instalar y lidiar con modelos de AI.
https://huggingface.co/TheBloke Distintos modelos y parámetros para poder ser ejecutados en máquinas mas modestas
https://github.com/jmorganca/ollama herramienta para ejecutar LLM de forma local y hacerle un wrapper python que te permita adaptar los prompts/salidas.

Terminología:

Cuantización: Reduce la precisión de los números utilizados para representar los pesos en el modelo, pierdes precisión pero el tamaño y la inferencia se acelera sin necesitar tantos recursos.
GPTQ : formato de cuantización
-AWQ : formato de cuantización
-GGUF : formato de modelos que reemplaza a GGML.
Tamaño del contexto: la cantidad de tokens (normalmente palabras ) que un modelo puede considerar en un prompt de una vez cuando genera una respuesta o predicción.
Formatos de los modelos models in open AI book
– Destilación (Distillation) es el proceso de entrenar un modelo pequeño (llamado estudiante) para emular las respuestas de un modelo mas grande (profesor).
-Pruning es el proceso de elminar las salidas no críticas del modelo pequeño o estudiante.
-Quantization es el proceso de reducir la precisión de los pesos del modelo y las activaciones.

Bibliografía

Open source LLM
XGen-7B Technical Report un LLM open source con un prompt de hasta 8K

Emulación en NS3

El proceso de simulación en el simulador NS3 es el más ampliamente utilizado. Ya lo hemos visto en entradas anteriores. Una característica menos conocida y menos utilizada que te permite NS3 es la emulación. Efectivamente, NS3 te permite crear un escenario (nodos, pila de protocolos y aplicaciones) y que el tráfico generado por ese escenario “salga” al mundo real. Por ejemplo, podemos crear un sensor en ns3 que genere tráfico de forma que el servidor que recoge la información no distinga si es un sensor real o no. Las aplicaciones de la emulación son muy numerosas, por ejemplo, para testear servicios software reales , probar configuraciones, crear perfiles de tráfico, etc.

Vamos a ver un ejemplo de cómo crear este tipo de escenarios, y luego iremos viendo cosas concretas. Nuestro objetivo es generar tráfico LoraWan de un nodo a una pasarela en NS3 y que ese tráfico salga por una interfaz virtual que se crea en el ordenador que ejecuta la simulación.

La parte de la simulación LoraWan ya la hemos visto y tenemos un ejemplo en los repositorios de este blog. Sigue las instrucciones para hacer el ejemplo si no lo has hecho todavía.

Vamos a partir de ese ejemplo de LoraWan básico que constaba de un nodo lora representando un sensor y una pasarela o hub que recibía ese tráfico.

Bien, vamos a coger ese ejemplo y vamos a modificarlo para que ese tráfico lora salga por una interfaz de red virtual que se crea en el ordenador que ejecuta la simulación. Para ello necesitamos dos cosas, una es añadir un nodo que denominaremos “fantasma” que recoja el tráfico de la red que llega a la pasarela LoraWan y la saque por la interfaz virtual. El otro elemento que necesitamos es una aplicación que reciba el tráfico de la interfaz LoRa y lo reenvíe por una interfaz CSMA (Ethernet). Esa aplicación la instalaremos en la pasarela LoraWan y le añadiremos una interfaz CSMA. Al otro extremo de la interfaz CSMA conectaremos el nodo ghost que será al que tendremos que conectar la interfaz virtual del tipo “TapBridge”.

Para ello, creamos una interfaz tapBridge, con su asistente:

TapBridgeHelper tapBridge;
tapBridge.SetAttribute ("Mode", StringValue ("ConfigureLocal"));
tapBridge.SetAttribute ("DeviceName", StringValue ("virtualtwin"));

Ahora vamos a crear un asistente para crear una red Ethernet que conecte el Ghostnode, y el hub que recoge el tráfico LoraWan, éste ghostnode será el que cree la interfaz virtual. Podríamos hacer que el hub que recoge el tráfico LoraWan creara la interfaz virtual, pero realmente los gateways/hub realmente recogen el tráfico y lo reenvian a una interfaz Ethernet y queríamos modelar también ese paso:


NodeContainer ghostNode;
ghostNode.Create(1);
ghostNode.Add(hub.Get(0));
CsmaHelper csma;
csma.SetChannelAttribute ("DataRate", StringValue ("100Mbps"));
csma.SetChannelAttribute ("Delay", TimeValue (NanoSeconds (6560)));

NetDeviceContainer hubDevice = csma.Install(ghostNode);

Básicamente se añade el hub (que ya lo habíamos creado previamente) al nuevo contenedor de nodos y se conectan todos a un canal csma. La expresión hub.Get(0) devuelve el nodo en la posición 0 del asistente de creador de nodos o contenedor de nodos, hub. Ese contenedor sólo tenía un nodo.

Asignamos la dirección IP en ese canal:

InternetStackHelper stack;
stack.Install (ghostNode);
Ipv4AddressHelper address;
address.SetBase ("10.0.0.0", "255.255.255.0");
Ipv4InterfaceContainer p2pInterfaces;
p2pInterfaces = address.Assign(hubDevice);

tapBridge.Install(hub.Get(0),hubDevice.Get(0));

y ya solo nos falta instalar las aplicaciones pertinentes, la primera es la aplicación que simulará el tráfico del sensor, ya la hemos visto:

PeriodicSenderHelper periodicSenderHelper;
periodicSenderHelper.SetPeriod (Seconds (12));
periodicSenderHelper.Install (nodes);

También necesitamos una aplicación que recoja toda la información que le llega a la interfaz lorawan del hub y la reenvíe por la interfaz csma que le hemos añadido. Por defecto, esa aplicación no existe, por lo que la hemos tenido que crear junto con su asistente para instalarlo. En otra entrada de blog la analizaremos, por el momento vamos a ver que se “instala” en el hub como otra aplicación cualquiera:

ForwardercdmaHelper ForwardercdmaHelper;
ForwardercdmaHelper.Install (hub);

Hemos incluido el código de esa aplicación (ForwaredercdmaHelper) en el ejemplo de esta entrada.

Al ejecutar la simulación podemos ver cómo se genera una interfaz “virtualtwin” mediante el comando ifconfig.

Created with GIMP

En esa interfaz sale todo el tráfico que mandemos al nodo ghost, entre ellos, el tráfico lorawan que hemos reenviado en el hub.

El ejemplo completo lo podemos encontrar en el directorio emulación del repositorio github

Comandos de la consola GNU/Linux (Debian 10: Bash)

Un comando es una orden que interpreta el intérprete de comandos y que sirve para interaccionar con el sistema operativo. Hay órdenes para realizar todo, gestionar archivos, ejecutar aplicaciones, administrar servicios de red, explorar internet, diagnosticar tu computador, etc.
Un comando está formado por el nombre del comando, una o varias opciones que modifican el comportamiento del comando y uno o varios argumentos que indican la ruta o archivo con el que el comando va a trabajar. Las opciones tienen un formato corto, una letra precedida por un guión y un formato largo, una palabra que indica la opción precedida por dos guiones. Aunque depende del programador que realizó el comando, algo más o menos estándar es que la opción -h imprima la ayuda del comando. Esa misma opción tiene un formato largo –help. Como ya he comentado, aunque existen prácticas comunes en cuanto a las opciones asociadas a la palabra en inglés que identifica la opción (h para la ayuda, v para sacar información de los pasos que está haciendo, etc.), cada comando puede seguir sus propias reglas.

Antes de empezar a ver ejemplos tres cosas que debes tener en cuenta:

  1. Los intérpretes de comandos en linux son sensibles a las mayúsculas por lo tanto ls es un comando distinto a LS. Por lo general, los nombres de comandos todos en minúsculas. Lo mismo para las opciones, generalmente, no es lo mismo -p que -P, aunque esto depende del creador del comando y, aunque recomendable, no está tan estandarizado.
  2. El tabulador autocompleta nombres de comandos, de directorios y de archivos. Aquí reside gran parte de la potencia de la consola y de su alta productividad, acostúmbrate a utilizar el tabulador. Generalmente la configuración por defecto está habilitada, si no, hay que habilitarla. Si quieres usar el comando mkdir, teclea mk y pulsa tabulador, si hay más de un comando que empieza por mk no hará nada, pulsa otra vez el tabulador y te sugerirá todos los comandos que empiezan por mk, sigue tecleando y cuando no sea ambiguo, si pulsas tabulador te lo completará.
  3. No hace falta “estudiarte” los comandos, poco a poco, los que más uses se te irán quedando, es buena idea imprimirte una hoja de comandos (por ejemplo, esta, esta otra, o esta) al principio para ir mirando los más habituales. En breve no te hará falta

Vamos a ver un ejemplo antes de seguir. El comando ls lista los directorios y archivos que hay dentro de un determinado directorio. En el gif de abajo, la primera vez que ejecutamos el comando ls, no ponemos ninguna opción ni argumento. Esto hace que ls coja las opciones y argumentos habilitadas por defecto, con lo cual, nos saca un listado de nombres con un código de colores (los azules son directorios) y del directorio donde se ejecuta ls.

Si queremos mas información podemos usar la opción -l, que te lista los archivos y directorios con mucha más información (que veremos en una futura entrada) del directorio en el cual ejecutamos el comando.
Si queremos listar los archivos y directorios, debemos indicárselo al comando ls, eso es lo que hacemos en las dos últimas ejecuciones, indicándole que liste el directorio padre de donde estoy actualmente (se indica con los dos puntos ..) por lo que lista el directorio del usuario alumno, y que liste el directorio ejemplo (que solo tiene una carpeta llamada uclm).

En ambos casos le agrego la opción -l para que liste los detalles de cada directorio o archivo que encuentre.

Pero ¿cómo puedo saber qué opciones y argumentos acepta un comando?, bueno, hay generalmente dos formas de ver qué opciones y argumentos contempla un comando (aparte de buscarlo en google) sin salir de dentro del terminal.

  1. Las opciones de ayuda del propio comando (los mas habituales –help o -h)
  2. Usar el comando de ayuda en linea man. El comando man toma como argumento de entrada cualquier comando y te muestra la ayuda si está disponible en el computador

Instalación del simulador NS3

Instrucciones actualizadas a la versión 3.36 (mayo 2022)

La forma mas simple de instalación del simulador NS3 pasa por bajarse el paquete completo de la web de versiones del simulador y descomprimirla en el directorio de trabajo. El tutorial de instalación completo puede consultarse en la wiki de instalación. En mi sistema Debian Buster, tras bajarme el archivo ns-allinone-3.36.tar.bz2 se descomprime:

$ tar -xf ns-allinone-3.36.tar.bz2
$ ls
ns-allinone-3.36 ns-allinone-3.36.tar.bz2
$ cd ns-allinone-3.36/
ns-allinone-3.36$ ls
bake build.py constants.py netanim-3.108 ns-3.36 pybindgen-0.22.1 README util.py

Atendiendo al sistema operativo donde estés trabajando, necesitas instalar una serie de dependencias y librerías que ns3 utiliza para los diferentes módulos. Hay dependencias que no necesitas instalar si el módulo que depende de esas librerías no lo vas a emplear. Si estás empezando, instala todas las dependencias que se indican en el wiki de instalación para tu sistema operativo. También te indican para qué sirve cada dependencia, mi recomendación es que, como ya he dicho, si estás empezando, las instales todas. En mi caso, las dependencias para Debian Buster vienen especificadas en su sección correspondiente usando la herramienta apt-get.
Una vez instaladas las dependencias existen varias alternativas para la instalación. Nosotros vamos a utilizar el script ns3, dentro del directorio ns-3.36. Este script tiene una funcionalidad parecida al antiguo sistema de compilación y configuración waf. Ese sistema, a partir de la versión 3.36 ha sido cambiado por Cmake. En el directorio donde hemos descomprimido el archivo ns-allinone-3.36.tar.bz2, , dentro de la carpeta ns-3.36, encontramos el archivo ns3. Lo ejecutamos para compilar el simulador:

./ns3 configure --enable-test --enable-examples

Se configura el simulador y al final te informa de qué módulos se compilarán. Las opciones habilitan los test y los ejemplos respectivamente, una opción que, si estás empezando, te servirán de guía en tus simulaciones. En mi caso, la información final es:

Modules configured to be built:
antenna aodv applications
bridge buildings config-store
core csma csma-layout
dsdv dsr energy
fd-net-device flow-monitor internet
internet-apps lr-wpan lte
mesh mobility netanim
network nix-vector-routing olsr
point-to-point point-to-point-layout propagation
sixlowpan spectrum stats
tap-bridge test topology-read
traffic-control uan virtual-net-device
wave wifi wimax

Modules that cannot be built:
brite click mpi
openflow visualizer

-- Configuring done
-- Generating done



Si algún módulo te interesa y no está en la lista, analiza la salida del comando por que posiblemente, el script ns3 no haya encontrado algo que necesita para compilar ese módulo o no lo tiene habilitado por defecto. Mas adelante veremos las opciones para habilitar distintos módulos.
Para compilar el simulador:

./ns3 build

Si todo ha ido bien, de nuevo, debería informarse de los módulos compilados:



Como último paso, comprobamos que todos los test pasan satisfactoriamente:

ns-allinone-3.36$cd ns-3.36
ns-allinone-3.36/ns-3.36$./test.py
..
..
657 of 660 tests passed (657 passed, 3 skipped, 0 failed, 0 crashed, 0 valgrind errors)
List of SKIPped tests:
ns3-tcp-cwnd (requires NSC)
ns3-tcp-interoperability (requires NSC)
nsc-tcp-loss (requires NSC)

y vemos que hemos pasado todos los test excepto tres relacionados con tcp que requieren de Network Simulation Cradle (NSC). Deberemos habilitar esa dependencia (y compilar todo) para pasar esos test.

De esta forma, ya tendríamos el simulador listo para trabajar y poder empezar a simular nuestros escenarios.

El sistema de log de NS3

Un buen entorno de logging te permite depurar y entender qué está pasando en tu simulación, así como entender cómo está estructurado el simulador NS3 y sus diferentes módulos.
El sistema de logging de NS3 se usa mediante variables de entorno y mediante el propio código de tu simulación si quieres habilitar logging en tu propia simulación.

Se establecen siete niveles de log proporcionando de menos a mas información:

  1. NS_LOG_ERROR: mensajes de error
  2. NS_LOG_WARN: mensajes de aviso
  3. NS_LOG_DEBUG: mensajes específicos de depuración
  4. NS_LOG_INFO: mensajes de información genéricos
  5. NS_LOG_FUNCTION: mensajes de llamadas a funciones para la trazabilidad de llamadas
  6. NS_LOG_LOGIC: mensajes de log con el flujo lógico dentro de cada función
  7. NS_LOGIC_ALL: todos los mensajes

Adicionalmente hay un nivel incondicional que imprime la salida con independencia de los niveles de log activos o no. Este es NS_LOG_UNCOND
¿Cómo visualizamos la información de log en ns3?, bien, vamos a comenzar con nuestro ejemplo IoT básico donde, precisamente, uno de los problemas es que no generábamos ningún tipo de información acerca de la simulación.
Efectivamente si lo ejecutamos, habiendo colocado previamente el archivo basic-iot-sensors.cc en el directorio scratch:

$./waf --run basic-iot-sensors
Waf: Entering directory `/ns-allinone-3.30.1/ns-3.30.1/build'
Waf: Leaving directory `/ns-allinone-3.30.1/ns-3.30.1/build'
Build commands will be stored in build/compile_commands.json
'build' finished successfully (2.047s)

Vemos que no sale ningún tipo de información útil acerca de la simulación salvo que se ha compilado y ejecutado sin errores.
Vamos a estudiar la información que nos genera el módulo de YansWifiPhy, para ello:

$ export NS_LOG=YansWifiPhy
felix@homer:~/tools/ns-allinone-3.30.1/ns-3.30.1$ ./waf --run basic-iot-sensors
Waf: Entering directory `/ns-allinone-3.30.1/ns-3.30.1/build'
Waf: Leaving directory `//ns-allinone-3.30.1/ns-3.30.1/build'
Build commands will be stored in build/compile_commands.json
'build' finished successfully (1.701s)
+0.000000000s -1 YansWifiPhy:YansWifiPhy(0x5641614960f0)
+0.000000000s -1 YansWifiPhy:SetChannel(0x5641614960f0, 0x564161490400)
+0.000000000s -1 YansWifiPhy:YansWifiPhy(0x56416153d640)
+0.000000000s -1 YansWifiPhy:SetChannel(0x56416153d640, 0x564161490400)
+0.000000000s -1 YansWifiPhy:YansWifiPhy(0x564161541e90)
...

vemos que usamos la variable de entorno NS_LOG para fijar el módulo en el que estamos interesados. Si ponemos NS_LOG a un nombre de un módulo que no existe se imprimirá un listado de todos los módulos que NS3 tiene implementados. El nivel de log por defecto en la mayoría de los módulos será todos, podemos especificarlo también a la hora de definir el módulo. Por ejemplo vamos a definir el nivel info para el módulo UdpClient:

$ export NS_LOG=UdpClient=level_info
$ ./waf --run basic-iot-sensors
Waf: Entering directory `/ns-allinone-3.30.1/ns-3.30.1/build'
Waf: Leaving directory `/ns-allinone-3.30.1/ns-3.30.1/build'
Build commands will be stored in build/compile_commands.json
'build' finished successfully (1.480s)
TraceDelay TX 32 bytes to 10.1.1.1 Uid: 0 Time: 2
TraceDelay TX 32 bytes to 10.1.1.1 Uid: 1 Time: 2
TraceDelay TX 32 bytes to 10.1.1.1 Uid: 2 Time: 2
TraceDelay TX 32 bytes to 10.1.1.1 Uid: 33 Time: 2.5
....

Vemos que ahora sale la información de las aplicaciones UDP instaladas en los nodos IoT enviando información. Substituye level_info por level_all en el primer comando para que veas como sale mucha más información.
Si algún módulo no se usa en tú simulación y pones NS_LOG a ese módulo no saldrá ninguna información adicional.
Si quieres indicar que el nombre de la función que genera el mensaje también se imprima, puedes hacer un OR con prefix_fund. En bash necesitarás comillas:

$export 'NS_LOG=UdpClient=level_all|prefix_func'

Para habilitar varios módulos, puedes encadenar usando : varios módulos.

$ export 'NS_LOG=UdpClient:UdpServer'

Vamos a crear un módulo para nuestra simulación y a sacar nuestro mensaje de log. Para ello, edita el archivo basic-iot-sensors.cc y añade estas dos líneas, por ejemplo, justo debajo de donde se inicia la función main:

int main (int argc, char *argv[]){
NS_LOG_COMPONENT_DEFINE ("IotEjemplo");
NS_LOG_INFO ("Creando la simulación");

Aunque ya estaba incluido en dicho archivo, si lo utilizas en tu simulación acuerdate incluir los archivos de cabezera de log (“ns3/log.h”).
En la primera función definimos el módulo IotEjemplo y en la segunda sacamos nuestro primer mensaje. Si ahora queremos ver esa información:

$export 'NS_LOG=IotEjemplo'
$ ./waf --run basic-iot-sensors
Waf: Entering directory `/ns-allinone-3.30.1/ns-3.30.1/build'
[2876/2963] Compiling scratch/basic-iot-sensors.cc
[2921/2963] Linking build/scratch/basic-iot-sensors
Waf: Leaving directory `/ns-allinone-3.30.1/ns-3.30.1/build'
Build commands will be stored in build/compile_commands.json
'build' finished successfully (6.339s)
IotEjemplo:main(): [INFO ] Creando la simulación

Donde, como podemos ver, se muestra nuestro mensaje.

Es buena idea usar el mecanismo de log en cualquier simulación de ns3 pero se hace imprescindible si aspiras a crear un nuevo módulo para el simulador destinado a ser usado por la comunidad.