jueves, 16 de noviembre de 2023

Agentes autónomos con Python

Agentes autónomos con Python

Los agentes autónomos, impulsados por la inteligencia artificial, son entidades capaces de tomar decisiones y realizar acciones sin intervención humana directa. En este artículo, exploraremos el fascinante mundo de los agentes autónomos utilizando Python, un lenguaje de programación poderoso y versátil.

¿Qué son los Agentes Autónomos?

Los agentes autónomos son programas de software o sistemas físicos diseñados para interactuar con su entorno y tomar decisiones de manera autónoma. Estos agentes son esenciales en campos como la robótica, el aprendizaje automático y la inteligencia artificial. Su capacidad para adaptarse y aprender de su entorno los convierte en herramientas valiosas para abordar problemas complejos.

Características Principales de los Agentes Autónomos:

  1. Toma de Decisiones: Los agentes autónomos pueden tomar decisiones basadas en la información disponible y en los objetivos establecidos.
  2. Sensores y Percepción: Utilizan sensores para recopilar información sobre su entorno y entender el contexto en el que operan.
  3. Actuadores y Acciones: Los actuadores permiten a los agentes autónomos realizar acciones físicas o virtuales en respuesta a sus decisiones.

Implementando un agente autónomo

A continuación vamos a revisar los pasos para implementar un agente autónomo:
Comentarios:

  1. Importar Librerías:

    • from collections import deque: Importar deque de la librería collections para una implementación eficiente de una cola.
from collections import deque
  1. Clase AutonomousVacuumCleaner:
    La clase AutonomousVacuumCleaner, que implementa la lógica de la aspiradora, contiene los siguientes métodos:
    • __init__: Inicializa la aspiradora con el espacio proporcionado y establece la posición inicial.
    • show_state: Muestra el estado actual del espacio.
    • has_dirt: Verifica si hay suciedad en alguna parte del espacio.
    • clean_cell: Marca la celda actual como “Limpio”.
    • move: Mueve la aspiradora en la dirección especificada, actualizando la posición y limpiando la celda.

Y se ve de la siguiente manera:

class AutonomousVacuumCleaner:
    def __init__(self, space):
        self.space = space
        self.position = (0, 0)  # Initial position

    def show_state(self):
        for row in self.space:
            print(row)
        print()

    def has_dirt(self):
        return any("Dirty" in row for row in self.space)

    def clean_cell(self):
        x, y = self.position
        self.space[x][y] = "Clean"

    def move(self, direction):
        x, y = self.position
        if direction == "up" and x > 0:
            x -= 1
        elif direction == "down" and x < len(self.space) - 1:
            x += 1
        elif direction == "left" and y > 0:
            y -= 1
        elif direction == "right" and y < len(self.space[0]) - 1:
            y += 1

        self.position = (x, y)
        self.clean_cell()
  1. Función bfs_cleaning:
    La función BFS (Breadth-First Search) búsqueda en amplitud, nos ayuda a optimizar el proceso de limpieza mediante la búsqueda de una secuencia óptima de pasos de acuerdo con la configuración del espacio que se desea limpiar, configurado previamente mediante la matriz proporcionada, sus actividades consisten en:
    • Inicializa la aspiradora, define movimientos posibles, y configura la cola BFS y los estados visitados.
    • Realiza BFS hasta que se encuentra una solución o se alcanza el límite máximo de iteraciones.
    • Copia el estado de la aspiradora para cada posible movimiento y verifica si el nuevo estado no ha sido visitado previamente.
    • Retorna la secuencia óptima de movimientos si se encuentra; de lo contrario, retorna None.

La implementación de la función es la siguiente:

def bfs_cleaning(space, max_iterations=1000):
    cleaner = AutonomousVacuumCleaner(space)
    movements = ["up", "down", "left", "right"]
    frontier = deque([(cleaner, [])])  # Queue for BFS, each element is a pair (state, sequence of movements)
    visited_states = set()

    iterations = 0
    while frontier and iterations < max_iterations:
        current_state, current_sequence = frontier.popleft()
        cleaner, current_sequence = current_state, current_sequence

        if not cleaner.has_dirt():
            return current_sequence

        for movement in movements:
            cleaner_copy = AutonomousVacuumCleaner([row.copy() for row in cleaner.space])
            cleaner_copy.position = cleaner.position
            cleaner_copy.move(movement)

            state_tuple = tuple(tuple(row) for row in cleaner_copy.space)
            if state_tuple not in visited_states:
                visited_states.add(state_tuple)
                new_sequence = current_sequence + [movement]
                frontier.append((cleaner_copy, new_sequence))

        iterations += 1

    return None  # No solution found within the limit
  1. Ejecución:
    Para ejecutar la implementación de nuestro agente autónomo de una aspiradora debemos realizar lo siguiente:
    • Crea una matriz de 3x3 como el espacio de limpieza.
    • Encuentra la secuencia óptima usando BFS.
    • Ejecuta la secuencia óptima, mostrando el estado en cada paso e indicando si la habitación está limpia.

Y en código se vería así:

# Create a space for the vacuum cleaner (3x3 matrix)
cleaning_space = [
    ["Dirty", "Dirty", "Dirty"],
    ["Dirty", "Clean", "Dirty"],
    ["Dirty", "Dirty", "Dirty"]
]

# Find the optimal sequence of movements
optimal_sequence = bfs_cleaning(cleaning_space)

# Execute the optimal sequence
if optimal_sequence:
    print("Optimal sequence as found")
    print(optimal_sequence)
    vacuum_cleaner = AutonomousVacuumCleaner(cleaning_space)
    for movement in optimal_sequence:
        vacuum_cleaner.show_state()
        vacuum_cleaner.move(movement)

    print("Room is clean!")
else:
    print("No solution found within the limit.")

Resultado

Tomando en cuenta que el inicio de la limpieza en la configuración del espacio a limpiar (matriz) comienza en la posición (0, 0), la ejecución de nuestro agente autónomo proporcionará una salida similar a la siguiente:

Optimal sequence as found
['up', 'down', 'down', 'right', 'right', 'up', 'up', 'left']
['Dirty', 'Dirty', 'Dirty']
['Dirty', 'Clean', 'Dirty']
['Dirty', 'Dirty', 'Dirty']

['Clean', 'Dirty', 'Dirty']
['Dirty', 'Clean', 'Dirty']
['Dirty', 'Dirty', 'Dirty']

['Clean', 'Dirty', 'Dirty']
['Clean', 'Clean', 'Dirty']
['Dirty', 'Dirty', 'Dirty']

['Clean', 'Dirty', 'Dirty']
['Clean', 'Clean', 'Dirty']
['Clean', 'Dirty', 'Dirty']

['Clean', 'Dirty', 'Dirty']
['Clean', 'Clean', 'Dirty']
['Clean', 'Clean', 'Dirty']

['Clean', 'Dirty', 'Dirty']
['Clean', 'Clean', 'Dirty']
['Clean', 'Clean', 'Clean']

['Clean', 'Dirty', 'Dirty']
['Clean', 'Clean', 'Clean']
['Clean', 'Clean', 'Clean']

['Clean', 'Dirty', 'Clean']
['Clean', 'Clean', 'Clean']
['Clean', 'Clean', 'Clean']

['Clean', 'Clean', 'Clean']
['Clean', 'Clean', 'Clean']
['Clean', 'Clean', 'Clean']

Room is clean!

Podemos ver que todas las celdas de la configuración han sido limpiadas (clean).

Los agentes autónomos son entidades capaces de operar de manera independiente en entornos específicos, tomando decisiones y realizando acciones sin intervención humana directa. Estos agentes se utilizan en una variedad de campos, desde robótica y inteligencia artificial hasta sistemas autónomos en vehículos y aplicaciones industriales. Aquí hay un resumen de las características clave de los agentes autónomos:

  1. Capacidad de Toma de Decisiones:

    • Los agentes autónomos tienen la capacidad de evaluar información de su entorno y tomar decisiones basadas en objetivos y reglas predefinidas.
  2. Sensores y Percepción:

    • Utilizan sensores para recopilar datos del entorno, lo que les permite percibir y comprender su situación. Estos sensores pueden incluir cámaras, micrófonos, radares, entre otros.
  3. Actuadores y Acciones:

    • Los actuadores permiten a los agentes realizar acciones físicas o virtuales en respuesta a sus decisiones. Ejemplos de actuadores incluyen motores, brazos robóticos, o incluso interfaces de software para interactuar con sistemas digitales.
  4. Entrenamiento y Aprendizaje:

    • Algunos agentes autónomos pueden aprender y adaptarse a su entorno a lo largo del tiempo. El aprendizaje puede ser supervisado, no supervisado o por refuerzo, permitiendo a los agentes mejorar su desempeño con la experiencia.
  5. Aplicaciones Diversas:

    • Se aplican en una amplia gama de campos, como la robótica doméstica, vehículos autónomos, sistemas de control industrial, asistentes virtuales, videojuegos y más.
  6. Algoritmos y Estrategias:

    • Los agentes autónomos a menudo implementan algoritmos de búsqueda, planificación y control para alcanzar sus objetivos de manera eficiente. Estrategias como la búsqueda de amplitud, búsqueda heurística y aprendizaje automático son comunes.
  7. Comunicación entre Agentes:

    • En entornos complejos, los agentes autónomos pueden necesitar comunicarse entre sí para coordinar acciones y lograr objetivos más grandes. Esto es especialmente evidente en sistemas multiagente.
  8. Ética y Responsabilidad:

    • A medida que los agentes autónomos se vuelven más avanzados, surgen cuestiones éticas y de responsabilidad. La programación y el comportamiento de estos agentes deben ser cuidadosamente diseñados para evitar resultados no deseados o peligrosos.

jueves, 26 de octubre de 2023

Algoritmo de Bellman-Ford en Python

Algoritmo de Bellman-Ford en Python

En la entrada anterior vimos la implementación del algoritmo de Dijkstra para calcular distancias cortas entre los nodos de un grafo. En esta ocasión vamos a revisar el algoritmo Bellman-Ford.

El algoritmo de Bellman-Ford es un algoritmo de búsqueda de caminos más cortos en un grafo que puede manejar aristas con pesos negativos, a diferencia del algoritmo de Dijkstra. Sin embargo, el algoritmo de Bellman-Ford es menos eficiente que el algoritmo de Dijkstra y tiene una complejidad de tiempo de O(V ⋅ E), donde V es el número de nodos y E es el número de aristas en el grafo.

Pseudocódigo del algoritmo

  1. Inicializar las distancias a todos los nodos como infinito, excepto el nodo de origen que se inicializa como 0.
  2. Para cada nodo en el grafo:
    • Para cada arista (u, v) en el grafo:
      • Si distancia[u] + peso(u, v) < distancia[v], actualizar distancia[v] = distancia[u] + peso(u, v).
  3. Repetir el paso 2 para un total de V-1 veces (V es el número de nodos en el grafo).
  4. Verificar si hay ciclos de peso negativo:
    • Para cada arista (u, v) en el grafo:
      • Si distancia[u] + peso(u, v) < distancia[v], entonces hay un ciclo de peso negativo en el grafo.
  5. Las distancias finales representan los caminos más cortos desde el nodo de origen a todos los demás nodos en el grafo.

Definición del código del algoritmo

De manera similar que el algoritmo de Dijkstra, para este ejemplo vamos a trabajar con clases de Python, así que lo primero que haremos será definir los métodos de la clase (Graph) para luego presentar el código completo de la misma.

Método __init__

Cómo vimos anteriormente, cuando se trabaja con clases en Python, es común definir el método __init__(self) para inicializar la instancia del objeto con valores que se requieran. De igual manera, en este caso vamos a inicializar la instancia con dos atributos, un conjunto para almacenar los nodos y un conjunto para almacenar las aristas.

def __init__(self):
    self.nodes = set()
    self.edges = set()

Método para agregar nodos

Este método recibe como parámetro el valor del nodo que se quiere agregar y lo inserta en el atributo nodes que definimos en el método __init__.

Nota: La idea de que los nodos sean un objeto del tipo set() es porque deben ser únicos y no deben existir nodos duplicados.

def addNode(self, value):
    self.nodes.add(value)

Método para agregar aristas

El método para agregar aristas a más del parámetro self recibe tres parámetros, origin para referirse al nodo de origen, destiny correspondiente al nodo de destino y weight que hace referencia al peso de la arista desde el origen hasta el destino. Con estos parámetros se crea una tupla que se va a agregar en el atributo edges de tipo set (conjunto) de la clase.

def addEdge(self, origin, destiny, weight):
    self.edges.add((origin, destiny, weight))

Método del algoritmo de Bellman-Ford

Para este método se requiere como parámetro el nodo de inicio desde donde se va a calcular las distancias. Con este valor ejecutamos los pasos definidos en el pseudocódigo de la siguiente manera:

def bellman_ford(self, inicio):
        # Paso 1: Inicializamos las distancias
        distancias = {nodo: float('infinity') for nodo in self.nodos}
        distancias[inicio] = 0

        # Paso 2: Ejecutamos la relajación de aristas V-1 veces
        for _ in range(len(self.nodos) - 1):
            for u, v, peso in self.aristas:
                if distancias[u] + peso < distancias[v]:
                    distancias[v] = distancias[u] + peso

        # Paso 4: Verificamos los ciclos de peso negativo
        for u, v, peso in self.aristas:
            if distancias[u] + peso < distancias[v]:
                print("El grafo contiene un ciclo de peso negativo")
                return

        # Las distancias finales representan los caminos más cortos desde el nodo de origen
        return distancias

Clase Graph

Con todos los métodos de la clase Graph definidos, vamos a ver cómo quedaría todo junto:

class Graph:
    def __init__(self):
        self.nodes = set()
        self.edges = set()

    def addNode(self, value):
        self.nodes.add(value)

    def addEdge(self, origin, destiny, weight):
        self.edges.add((origin, destiny, weight))

    def bellman_ford(self, start):
        # Paso 1: Inicializar las distancias
        distances = {node: float('infinity') for node in self.nodes}
        distances[start] = 0

        # Paso 2: Relajación de aristas V-1 veces
        for _ in range(len(self.nodes) - 1):
            for u, v, weight in self.edges:
                if distances[u] + weight < distances[v]:
                    distances[v] = distances[u] + weight

        # Paso 4: Verificar ciclos de peso negativo
        for u, v, weight in self.edges:
            if distances[u] + weight < distances[v]:
                print("The graph contains a negative weight cycle")
                return

        # Las distancias finales representan los caminos más cortos desde el nodo de origen
        return distances

Probando el funcionamiento

Una vez definida la lógica de la clase Graph, podemos realizar pruebas para validar que la implementación funciona de manera correcta. Para esto, igual que antes lo primero que debemos hacer es crear una instancia de la clase:

graph = Graph()

Con esta instancia creamos los nodos de nuestro grafo de la misma manera que antes, es decir 8 nodos correspondientes a las letras de la A hasta la H.

graph.addNode('A')
graph.addNode('B')
graph.addNode('C')
graph.addNode('D')
graph.addNode('E')
graph.addNode('F')
graph.addNode('G')
graph.addNode('H')

Así mismo creamos las aristas con los mismos valores que en el ejemplo anterior para tener el mismo grafo:

graph.addEdge('A', 'B', 4)
graph.addEdge('A', 'C', 9)
graph.addEdge('A', 'E', 7)
graph.addEdge('A', 'F', 3)
graph.addEdge('A', 'G', 11)
graph.addEdge('A', 'H', 15)
graph.addEdge('B', 'C', 4)
graph.addEdge('B', 'D', 4)
graph.addEdge('B', 'E', 2)
graph.addEdge('B', 'G', 9)
graph.addEdge('C', 'D', 8)
graph.addEdge('C', 'F', 2)
graph.addEdge('D', 'G', 11)
graph.addEdge('D', 'F', 8)
graph.addEdge('D', 'E', 5)
graph.addEdge('D', 'H', 4)
graph.addEdge('E', 'G', 3)
graph.addEdge('E', 'H', 14)

El grafo creado tiene la siguiente estructura:

Ahora debemos definir cuál va a ser nuestro punto de partida y con eso calcular las distancias:

start = 'A'
distances = graph.bellman_ford(start)

Por último vamos a mostrar cuáles son los caminos más cortos desde el nodo de partida A hacia el resto de los nodos, para eso recorremos todos los elementos de las distancias e imprimimos el resultado:

print("Distancias más cortas desde el nodo", start)
for node, distance in distances.items():
    print(f"A -> {node}: {distance}")

Y obtenemos el siguiente resultado, que como podemos apreciar nos muestra las mismas distancias que el algoritmo de Dijkstra:

Distancias más cortas desde el nodo A
A -> F: 3
A -> E: 6
A -> G: 9
A -> C: 8
A -> A: 0
A -> D: 8
A -> H: 12
A -> B: 4

miércoles, 25 de octubre de 2023

Algoritmo de Dijkstra en Python

Algoritmo de Dijkstra en Python

El algoritmo de Dijkstra es un algoritmo clásico en teoría de grafos utilizado para encontrar el camino más corto desde un nodo de origen a todos los demás nodos en un grafo ponderado (con pesos en las aristas). La lógica del algoritmo de Dijkstra se presenta a continuación:

Definiciones Previas:

  • Grafo Ponderado: Es aquel grafo en el que cada arista tiene un peso (o costo) asociado.
  • Distancia: Corresponde a la longitud del camino más corto entre dos nodos.
  • Nodo de Origen: El nodo desde el cual se inicia la búsqueda del camino más corto.

Pasos del algoritmo

  1. Inicialización:
    - Crea un conjunto de nodos no visitados y asigna una distancia inicial de infinito a todos los nodos excepto al nodo de origen, al que se le asigna una distancia de 0.
    • Crea un conjunto vacío de nodos visitados.
  2. Bucle Principal:
    - Mientras haya nodos no visitados:
    - Encuentra el nodo no visitado con la distancia mínima del conjunto de nodos no visitados. Llamémoslo nodo_actual.
    - Marca nodo_actual como visitado y lo elimina del conjunto de nodos no visitados.
    • Para cada vecino vecino del nodo_actual que aún no ha sido visitado:
      • Calcula la nueva distancia desde el nodo de origen hasta vecino pasando por nodo_actual.
      • Si esta nueva distancia es menor que la distancia almacenada para vecino, actualiza la distancia almacenada para vecino.
  3. Fin del Algoritmo:
  • Una vez que todos los nodos hayan sido visitados o el nodo de destino haya sido visitado, el algoritmo termina. Las distancias calculadas representan los caminos más cortos desde el nodo de origen a cada nodo en el grafo.

Definición del código del algoritmo

Una vez vista la lógica del algoritmo, vamos a proceder a la creación del código del mismo. En esta ocasión vamos a trabajar con clases en Python para definir la clase Graph que nos permitirá definir la lógica del algoritmo. Esta clase Graph va a contener los métodos para agregar nodos, agregar aristas y ejecutar el algoritmo de Dijkstra para determinar los caminos más cortos.

Vamos a ver cómo definimos los métodos de la clase Graph para luego verlo todo junto:

Método __init__

Cuando se trabaja con clases en Python, es común definir el método __init__(self) para inicializar la instancia del objeto con valores que se requieran. En este caso vamos a inicializar la instancia con dos atributos, un conjunto para almacenar los nodos y un diccionario para almacenar las aristas.

Nota Cuando se trabaja con clases, es común utilizar la palabra reservada self en la definición de los métodos para recibirla como argumento, ya que esta declaración hace referencia a la clase misma y a través de self puede acceder a todos los componentes de la clase.

def __init__(self):
    self.nodes = set()
    self.edges = {}

Método para agregar nodos

Este método recibe como parámetro el valor del nodo que se quiere agregar y lo inserta en el atributo nodes que definimos en el método __init__. Adicional a esto agrega una entrada en el diccionario de aristas en donde la clave corresponde al valor del nodo y el valor se asigna como una lista vacía, que se usará luego para agregar los nodos a los que está conectado (es decir las aristas).

Nota: La idea de que los nodos sean un objeto del tipo set() es porque deben ser únicos y no deben existir nodos duplicados.

def addNode(self, value):
    self.nodes.add(value)
    self.edges[value] = []

Método para agregar aristas

El método para agregar aristas a más del parámetro self recibe tres parámetros, origin para referirse al nodo de origen, destiny correspondiente al nodo de destino y weight que hace referencia al peso de la arista desde el origen hasta el destino. Esta relación se ingresa en los dos sentidos

def addEdge(self, origin, destiny, weight):
    self.edges[origin].append((destiny, weight))

Método del algoritmo de Dijkstra

Para definir este método se debe recalcar que se utiliza la biblioteca heapq de Python que consiste en un módulo que proporciona implementaciones de colas de prioridad utilizando estructuras de datos de montículos binarios (heaps). Los montículos binarios son estructuras de datos que mantienen una propiedad especial: el elemento en la posición i es siempre menor o igual que los elementos en las posiciones 2i+1 y 2i+2. Esto permite implementar fácilmente colas de prioridad.

Nota: Si se utiliza una cola de prioridad (por ejemplo, un montículo binario) para la implementación del algoritmo de Dijkstra, el tiempo de ejecución es O((V + E) log V), donde V es el número de nodos y E es el número de aristas en el grafo.

    def dijkstra(self, start):
        # Realizamos la inicialización definiendo una distancia de valor infinito
        # a cada uno de los nodos no visitados
        distances = {node: float('infinity') for node in self.nodes}
        # Adignamos la distancia de 0 la nodo de origen
        distances[start] = 0
        # Creamos un conjunto de nodos visitados
        tempQueue = [(0, start)]
        # Iniciamos el bucle principal
        while tempQueue:
            # Obtenemos el nodo con la distancia más baja de la cola
            actualDistance, actualNode = heapq.heappop(tempQueue)
            # Si encontramos una distancia más larga a un nodo, omitimos este nodo
            if actualDistance > distances[actualNode]:
                continue
            for neighbor, weight in self.edges[actualNode]:
                # Calculamos la distancia tentativa al vecino
                distance = actualDistance + weight
                if distance < distances[neighbor]:
                    # Actualizamos la distancia si encontramos un camino más corto
                    distances[neighbor] = distance
                    # Agregamos el vecino a la cola de prioridad
                    heapq.heappush(tempQueue, (distance, neighbor))
        # Devuelve las distancias más cortas desde el nodo de inicio a todos los demás nodos del grafo
        return distances

Clase Graph

Una vez definidos todos los métodos de la clase Graph, vamos a ver cómo quedaría todo junto:

import heapq

class Graph:
    def __init__(self):
        self.nodes = set()
        self.edges = {}

    def addNode(self, value):
        self.nodes.add(value)
        self.edges[value] = []

    def addEdge(self, origin, destiny, weight):
        self.edges[origin].append((destiny, weight))

    def dijkstra(self, start):
        # Realizamos la inicialización definiendo una distancia de valor infinito
        # a cada uno de los nodos no visitados
        distances = {node: float('infinity') for node in self.nodes}
        # Adignamos la distancia de 0 la nodo de origen
        distances[start] = 0
        # Creamos un conjunto de nodos visitados
        tempQueue = [(0, start)]
        # Iniciamos el bucle principal
        while tempQueue:
            # Obtenemos el nodo con la distancia más baja de la cola
            actualDistance, actualNode = heapq.heappop(tempQueue)
            # Si encontramos una distancia más larga a un nodo, omitimos este nodo
            if actualDistance > distances[actualNode]:
                continue
            for neighbor, weight in self.edges[actualNode]:
                # Calculamos la distancia tentativa al vecino
                distance = actualDistance + weight
                if distance < distances[neighbor]:
                    # Actualizamos la distancia si encontramos un camino más corto
                    distances[neighbor] = distance
                    # Agregamos el vecino a la cola de prioridad
                    heapq.heappush(tempQueue, (distance, neighbor))
        # Devuelve las distancias más cortas desde el nodo de inicio a todos los demás nodos del grafo
        return distances

Probando el funcionamiento

Una vez definida la lógica de la clase Graph, podemos realizar pruebas para validar que la implementación funciona de manera correcta. Para esto lo primero que debemos hacer es crear una instancia de la clase:

graph = Graph()

Ahora vamos a crear los nodos de nuestro grafo, para este caso vamos a crear 8 nodos correspondientes a las letras de la A hasta la H.

graph.addNode('A')
graph.addNode('B')
graph.addNode('C')
graph.addNode('D')
graph.addNode('E')
graph.addNode('F')
graph.addNode('G')
graph.addNode('H')

Con los nodos creados podemos definir las aristas con sus respectivos pesos:

graph.addEdge('A', 'B', 4)
graph.addEdge('A', 'C', 9)
graph.addEdge('A', 'E', 7)
graph.addEdge('A', 'F', 3)
graph.addEdge('A', 'G', 11)
graph.addEdge('A', 'H', 15)
graph.addEdge('B', 'C', 4)
graph.addEdge('B', 'D', 4)
graph.addEdge('B', 'E', 2)
graph.addEdge('B', 'G', 9)
graph.addEdge('C', 'D', 8)
graph.addEdge('C', 'F', 2)
graph.addEdge('D', 'G', 11)
graph.addEdge('D', 'F', 8)
graph.addEdge('D', 'E', 5)
graph.addEdge('D', 'H', 4)
graph.addEdge('E', 'G', 3)
graph.addEdge('E', 'H', 14)

Con esto tenemos nuestro grafo listo, y se encuentra configurado de la siguiente manera:

Ahora debemos definir cuál va a ser nuestro punto de partida y con eso calcular las distancias:

inicio = 'A'
distancias = graph.dijkstra(inicio)

Por último vamos a mostrar cuáles son los caminos más cortos desde el nodo de partida A hacia el resto de los nodos, para eso recorremos todos los elementos de las distancias e imprimimos el resultado:

print("Distancias más cortas desde el nodo", inicio)
for nodo, distancia in distancias.items():
    print(f"A -> {nodo}: {distancia}")

Y obtenemos el siguiente resultado:

Distancias más cortas desde el nodo A
A -> C: 8
A -> G: 9
A -> B: 4
A -> E: 6
A -> D: 8
A -> F: 3
A -> H: 12
A -> A: 0