INTRODUCCIÓN

En este ejercicio, el objetivo es automatizar y generalizar el problema propuesto en RPubs sobre redes neuronales, considerando esta vez toda la matriz de datos correspondiente al problema XOR. Para ello, los datos se organizaron en una matriz mediante la función matrix, y de forma análoga se definieron los pesos y los sesgos.

X <- matrix(c(0,0, 0,1, 1,1, 1,0), ncol = 2, byrow = TRUE)
Y <- matrix(c(0, 1, 0, 1), byrow = TRUE)

x1 <- c(0, 0, 1, 1)
x2 <- c(0, 1, 0, 1)
y <- c(0, 1, 0, 1)

logic <- data.frame(x1, x2, y)

logic %>%
  kbl() %>%
  kable_material(c("striped", "hover"))
x1 x2 y
0 0 0
0 1 1
1 0 0
1 1 1


SOLUCIÓN

La idea en esencia es la misma, ya que se aplican los mismos pasos: primero se calculan las neuronas ocultas \(h_1\) y \(h_2\), luego la neurona de salida, y posteriormente se lleva a cabo el proceso de forward y backpropagation, finalizando con la actualización de los pesos.

La diferencia radica en que, en lugar de emplear únicamente una fila de la matriz —lo que hace que el aprendizaje sea menos representativo del ajuste a los datos reales— se utiliza toda la matriz de entrenamiento. De esta forma, en cada iteración se realizan los cálculos para todos los ejemplos y se van acumulando los resultados, logrando un aprendizaje más completo.

Este método se conoce como Batch. A diferencia de otros enfoques, en lugar de actualizar los pesos en cada iteración individual, se van acumulando las actualizaciones (sumándolas) durante todo el lote de datos. Al finalizar, se calcula un promedio de dichas actualizaciones en función del número de datos, y con ese valor se ajustan los pesos. Este proceso se repite durante el número de épocas establecido.


GRÁFICOS

En la parte gráfica, el sistema no cambia de manera significativa. La diferencia es que, en lugar de trabajar con variables de valores fijos previamente determinados (ya que sabíamos a qué fila correspondía cada dato), ahora se representan mediante las variables \(\boldsymbol{X_1}\) y \(\boldsymbol{X_2}\), las cuales varían en función de la fila en la que nos encontremos dentro de la matriz de datos. De esta forma, la representación puede visualizarse del siguiente modo:

import networkx as nx   #Permite trabajar con grafos en Python.
import matplotlib.pyplot as plt #Se usa para graficar.

G = nx.DiGraph() #Creamos un grafo dirigido


# Nodos
G.add_node('X1', pos=(0,0.7)) #Añadimos un nodo con la etiqueta X1, en la posición (0,1)
G.add_node('X2', pos=(0,0.3))
G.add_node('H1', pos=(1,0.7))
G.add_node('H2', pos=(1,0.3))
G.add_node('O1', pos=(2,0.5))

#Sesgos
G.add_node('bH', pos = (0.4, 1.1))
G.add_node('bO', pos = (1.4, 1.0))

# Aristas
Aristas = [('X1','H1'), ('X1','H2'), ('X2','H1'), ('X2','H2'), ('H1','O1'), ('H2','O1')] #Cada tupla (origen, destino) representa una flecha de entrada a salida.
G.add_edges_from(Aristas) #Añade esas conexiones al grafo.

# Aristas de sesgo
G.add_edge('bH','H1')
G.add_edge('bH','H2')
G.add_edge('bO','O1')
Aristas_Sesgos = [('bH','H1'), ('bH','H2'), ('bO','O1')]


w1 = 0.1; w2 = 0.5
w3 = -0.7; w4 = 0.3
b1 = 0; b2 = 0

w5 = 0.2; w6 = 0.4
b3 = 0

W_Ocultos = [[w1, w2],
             [w3, w4]]
W_Salida = [w5, w6]

b_Ocultos = [[b1, b2]]
b_Salida = b3

edge_labels = {
    ('X1','H1'): f"w1 = {W_Ocultos[0][0]:.1f}", #Indica que es la arista que va de la neurona X1 a H1. Usa fstring para convertir el valor del peso W_Ocultos[0][0] a un string con 1 decimal.
    ('X1','H2'): f"w2 = {W_Ocultos[0][1]:.1f}",
    ('X2','H1'): f"w3 = {W_Ocultos[1][0]:.1f}",
    ('X2','H2'): f"w4 = {W_Ocultos[1][1]:.1f}",
    ('H1','O1'): f"w5 = {W_Salida[0]:.1f}",
    ('H2','O1'): f"w6 = {W_Salida[1]:.1f}"
}

sesgos_edge_labels = {
    ('bH','H1'): r"$\mathbf{b_1=0.1}$",
    ('bH','H2'): r"$\mathbf{b_2=-0.7}$",
    ('bO','O1'): r"$\mathbf{b_3=0.2}$"
}


# Posiciones
pos = nx.get_node_attributes(G, 'pos') #Diccionario de posiciones de cada nodo. Se usa para dibujar la red en esas coordenadas específicas.

nodos = ['X1','X2','H1','H2','O1']

# Dibujar
nx.draw(G, pos, with_labels=True, nodelist=nodos, node_size=2000, node_color=['red','red','red','red','red'], font_weight='bold', edgecolors='black', linewidths=2, labels = {
    'X1': r'$\mathbf{X_1}$',
    'X2': r'$\mathbf{X_2}$',
    'H1': r'$\mathbf{h_1}$',
    'H2': r'$\mathbf{h_2}$',
    'O1': r'$\mathbf{O_1}$'
}); #nx.draw dibuja el grafo (pos: Coordenadas de los nodos; with_labels=True: Muestra el nombre de cada nodo; node_size: El tamaño de los nodos; node_color: El color de cada nodo)

#Dibujar sesgos
sesgos = ['bH','bO']
_ = nx.draw_networkx_nodes(
    G,
    pos,
    nodelist=sesgos,
    node_shape='^',  # triángulo
    node_color=['gray','gray'],
    node_size=1500, edgecolors='black', linewidths=2
);

sesgos_node_labels = {
    'bH': r'$\mathbf{1}$',
    'bO': r'$\mathbf{1}$'
}
_=nx.draw_networkx_labels(G, pos, labels=sesgos_node_labels, font_size=12);

_=nx.draw_networkx_edges(
    G, pos,
    edgelist=Aristas_Sesgos,          # Qué aristas dibujar
    edge_color='green',           # Color de la flecha
    arrows=True,                # Asegura que se dibujen flechas
    width=2                     # Grosor de la flecha
);

_=nx.draw_networkx_edge_labels(G, pos, edge_labels=edge_labels, label_pos=0.7, font_weight='bold', font_size = 10); #Dibuja etiquetas sobre las aristas de un grafo. edge_labels: Un diccionario que dice que mostrar en cada arista.
_ = nx.draw_networkx_edge_labels(G, pos, edge_labels=sesgos_edge_labels, font_size=10, label_pos = 0.65, font_color='green');

plt.show() #Muestra el grafo

Entonces, a medida que el ciclo avanza sobre las filas de la matriz, las variables \(\boldsymbol{X_1}\) y \(\boldsymbol{X_2}\) van tomando los valores correspondientes. Es decir, dado que existen 4 filas en el conjunto de datos, durante un ciclo completo estas variables adoptan, de forma secuencial, los valores de cada fila. De esta manera, en cada iteración se van actualizando según la posición dentro de la matriz.

import networkx as nx   #Permite trabajar con grafos en Python.
import matplotlib.pyplot as plt #Se usa para graficar.


def dibujar_red(X1_val, X2_val, O1_val):
    G = nx.DiGraph()

    # Nodos
    G.add_node('X1', pos=(0,0.7))
    G.add_node('X2', pos=(0,0.3))
    G.add_node('H1', pos=(1,0.7))
    G.add_node('H2', pos=(1,0.3))
    G.add_node('O1', pos=(2,0.5))

    # Sesgos
    G.add_node('bH', pos = (0.4, 1.1))
    G.add_node('bO', pos = (1.4, 1.0))

    # Aristas
    Aristas = [('X1','H1'), ('X1','H2'),
               ('X2','H1'), ('X2','H2'),
               ('H1','O1'), ('H2','O1')]
    G.add_edges_from(Aristas)

    # Aristas de sesgo
    Aristas_Sesgos = [('bH','H1'), ('bH','H2'), ('bO','O1')]
    G.add_edges_from(Aristas_Sesgos)

    # Pesos
    w1, w2, w3, w4 = 0.1, 0.5, -0.7, 0.3
    w5, w6 = 0.2, 0.4
    edge_labels = {
        ('X1','H1'): f"w1 = {w1:.1f}",
        ('X1','H2'): f"w2 = {w2:.1f}",
        ('X2','H1'): f"w3 = {w3:.1f}",
        ('X2','H2'): f"w4 = {w4:.1f}",
        ('H1','O1'): f"w5 = {w5:.1f}",
        ('H2','O1'): f"w6 = {w6:.1f}"
    }
    sesgos_edge_labels = {
        ('bH','H1'): r"$\mathbf{b_1=0.1}$",
        ('bH','H2'): r"$\mathbf{b_2=-0.7}$",
        ('bO','O1'): r"$\mathbf{b_3=0.2}$"
    }

    pos = nx.get_node_attributes(G, 'pos')
    nodos = ['X1','X2','H1','H2','O1']

    # --- Dibujar nodos principales ---
    nx.draw(G, pos, with_labels=True, nodelist=nodos,
            node_size=2000,
            node_color=['red']*len(nodos),
            font_weight='bold', edgecolors='black',
            linewidths=2,
            labels={
                'X1': r'$\mathbf{X_1}$' + "\n" + rf'$\mathbf{{{X1_val}}}$',
                'X2': r'$\mathbf{X_2}$' + "\n" + rf'$\mathbf{{{X2_val}}}$',
                'H1': r'$\mathbf{h_1}$',
                'H2': r'$\mathbf{h_2}$',
                'O1': r'$\mathbf{O_1}$' + "\n" + rf'$\mathbf{{{O1_val}}}$'
            })

    # --- Dibujar sesgos ---
    sesgos = ['bH','bO']
    nx.draw_networkx_nodes(
        G, pos, nodelist=sesgos, node_shape='^',
        node_color=['gray','gray'], node_size=1500,
        edgecolors='black', linewidths=2
    )
    sesgos_node_labels = {'bH': r'$\mathbf{1}$','bO': r'$\mathbf{1}$'}
    nx.draw_networkx_labels(G, pos, labels=sesgos_node_labels, font_size=12)

    # --- Dibujar aristas ---
    nx.draw_networkx_edges(G, pos, edgelist=Aristas_Sesgos,
                           edge_color='green', arrows=True, width=2)
    nx.draw_networkx_edge_labels(G, pos, edge_labels=edge_labels,
                                 label_pos=0.35, font_weight='bold', font_size=8)
    nx.draw_networkx_edge_labels(G, pos, edge_labels=sesgos_edge_labels,
                                 font_size=10, label_pos=0.65, font_color='green')



_ =plt.figure(figsize=(12,12))  #Crea una nueva figura donde se van a crear los gráficos. "Figsize" define el tamaño total de la figura: ancho = 14, alto = 6

_ =plt.subplot(2,2,1) #Divide la figura en una grilla de 1 fila y 2 columnas. (filas, columnas, índice) El último número (1) indica que el gráfico se pondrá en la primera posición.
dibujar_red(X1_val=0, X2_val=0, O1_val=0) #Llama a la función
_ =plt.title("Fila 1: $\mathbf{X_1 = 0, X_2 = 0, y = 0}$");

plt.subplot(2,2,2)
dibujar_red(X1_val=0, X2_val=1, O1_val=1)
_ =plt.title("Fila 2: $\mathbf{X_1 = 0, X_2 = 1, y = 1}$");

_ =plt.subplot(2,2,3) #Divide la figura en una grilla de 1 fila y 2 columnas. (filas, columnas, índice) El último número (1) indica que el gráfico se pondrá en la primera posición.
dibujar_red(X1_val=1, X2_val=0, O1_val=1) #Llama a la función
_ =plt.title("Fila 3: $\mathbf{X_1 = 0, X_2 = 0, y = 1}$");

_ =plt.subplot(2,2,4)
dibujar_red(X1_val=1, X2_val=1, O1_val=0)
_ =plt.title("Fila 4: $\mathbf{X_1 = 1, X_2 = 1, y = 0}$");

plt.tight_layout()
plt.show()

Vemos que el procedimiento es prácticamente el mismo que cuando trabajábamos con una sola fila: para cada fila de la matriz se realiza el mismo proceso de cálculo de las neuronas de la red. La diferencia está en que, en lugar de actualizar los pesos inmediatamente después de cada fila, estos se van acumulando. Es por ello que, en los gráficos, el comportamiento parece individual, ya que en cada iteración se trabaja con una fila a la vez.


CÁLCULOS DE ÉPOCAS

Al aplicar el método Batch mencionado, se promedian los gradientes acumulados y se actualizan los pesos. De esta manera, los resultados finales al concluir la primera época son los siguientes:

Sigmoide <- function(x){
  1 / (1 + exp(-x))
}

Dev_Sigmoide <- Deriv(Sigmoide, "x")

w1 <- 0.1; w2 <- 0.5
w3 <- -0.7; w4 <- 0.3
b1 <- 0; b2 <- 0

w5 <- 0.2; w6 <- 0.4
b3 <- 0

#Fijamos los pesos
W_Ocultos <- matrix(c(w1, w3, w2, w4), nrow = 2, byrow = TRUE)
W_Salida <- matrix(c(w5, w6), nrow = 1, byrow = TRUE)


#Fijamos los sesgos
b_Ocultos <- c(b1, b2)
b_Salida <- b3


Entrenar_Red <- function(X, Y, 
                         W_Ocultos, W_Salida, 
                         b_Ocultos, b_Salida, 
                         epocas = 10, Tasa_Aprendizaje = 0.25, epoca_inicial = 1) {
  
  resultados <- list()
  
  for (epoca in 1:epocas){
    # Creamos acumuladores
    Gradiente_Capa_Salida_Suma = matrix(0, nrow(W_Salida), ncol(W_Salida))
    Gradiente_Capa_Oculto_Suma = matrix(0, nrow(W_Ocultos), ncol(W_Ocultos))
    Gradiente_b_Salida_Suma <- 0
    Gradiente_b_Ocultos_Suma <- numeric(length(b_Ocultos))
    Error_Total = 0
    
    for (i in 1:nrow(X)){
      Entradas <- matrix(X[i, ], ncol = 1)
      Objetivo <- matrix(Y[i, ], ncol = 1)
      
      # --- FORWARD ---
      Z_Ocultos <- W_Ocultos %*% Entradas + b_Ocultos
      f_Ocultos <- Sigmoide(Z_Ocultos)
      
      Z_Salida <- W_Salida %*% f_Ocultos + b_Salida
      f_Salida <- Sigmoide(Z_Salida)
      
      # Error
      Error <- 0.5 * (Objetivo - f_Salida) ^ 2
      Error_Total = Error_Total + Error
      
      # --- BACKPROP ---
      # Salida
      DError_Df <- -(Objetivo - f_Salida)
      Df_Dz <- Dev_Sigmoide(Z_Salida)
      DError_Dz   <- DError_Df * Df_Dz
      
      # Gradiente de salida
      DError_Dw_Salida <- DError_Dz %*% t(f_Ocultos)
      Gradiente_Capa_Salida_Suma = Gradiente_Capa_Salida_Suma + DError_Dw_Salida
      Gradiente_b_Salida_Suma <- Gradiente_b_Salida_Suma + DError_Dz
      
      # Ocultas
      DError_Dz_Oculto <- (t(W_Salida) %*% DError_Dz) * Dev_Sigmoide(Z_Ocultos)
      DError_Dw_Oculto <- DError_Dz_Oculto %*% t(Entradas)
      Gradiente_Capa_Oculto_Suma = Gradiente_Capa_Oculto_Suma + DError_Dw_Oculto
      Gradiente_b_Ocultos_Suma <- Gradiente_b_Ocultos_Suma + DError_Dz_Oculto
    }
    
    # --- PROMEDIO ---
    Gradiente_Capa_Oculto_Suma = Gradiente_Capa_Oculto_Suma / nrow(X)
    Gradiente_Capa_Salida_Suma = Gradiente_Capa_Salida_Suma / nrow(X)
    Gradiente_b_Ocultos_Suma = Gradiente_b_Ocultos_Suma / nrow(X)
    Gradiente_b_Salida_Suma = Gradiente_b_Salida_Suma / nrow(X)
    
    # --- ACTUALIZAR ---
    W_Ocultos <- W_Ocultos - Tasa_Aprendizaje * Gradiente_Capa_Oculto_Suma
    W_Salida  <- W_Salida  - Tasa_Aprendizaje * Gradiente_Capa_Salida_Suma
    b_Ocultos <- b_Ocultos - Tasa_Aprendizaje * Gradiente_b_Ocultos_Suma
    b_Salida  <- b_Salida  - Tasa_Aprendizaje * Gradiente_b_Salida_Suma
    
    resultados[[epoca]] <- c(
      Epoca = epoca + epoca_inicial - 1,
      as.vector(W_Ocultos),
      as.vector(W_Salida),
      as.vector(b_Ocultos),
      b_Salida,
      ErrorPromedio = Error_Total / nrow(X)
    )
  }
  
  # Convertir a tabla
  tabla_resultados <- do.call(rbind, resultados)
  colnames(tabla_resultados) <- c(
    "Epoca",
    "$\\boldsymbol{w_1}$", "$\\boldsymbol{w_2}$", "$\\boldsymbol{w_3}$", "$\\boldsymbol{w_4}$",
    "$\\boldsymbol{w_5}$", "$\\boldsymbol{w_6}$",
    "$\\boldsymbol{b_1}$", "$\\boldsymbol{b_2}$", "$\\boldsymbol{b_3}$",
    "$\\boldsymbol{E_{total}}$"
  )
  
  return(list(
    tabla = tabla_resultados,
    W_Ocultos = W_Ocultos,
    W_Salida = W_Salida,
    b_Ocultos = b_Ocultos,
    b_Salida = b_Salida
  ))
}

res <- Entrenar_Red(X, Y, W_Ocultos, W_Salida, b_Ocultos, b_Salida, epocas = 1, Tasa_Aprendizaje = 0.25, 1)
res$tabla %>%
  knitr::kable(
    format = "html",              
    caption = "Evolución de los pesos, sesgos y error por época",
    digits = 5,                   
    align = "c"                   
  ) %>%
  kableExtra::kable_styling(
    bootstrap_options = c("striped", "hover", "condensed", "responsive"),
    full_width = FALSE,
    position = "center"
  ) %>%
  kableExtra::row_spec(1, bold = TRUE, color = "white", background = "steelblue") %>%  
  kableExtra::column_spec(1, bold = TRUE, color = "white", background = "darkred")
Evolución de los pesos, sesgos y error por época
Epoca \(\boldsymbol{w_1}\) \(\boldsymbol{w_2}\) \(\boldsymbol{w_3}\) \(\boldsymbol{w_4}\) \(\boldsymbol{w_5}\) \(\boldsymbol{w_6}\) \(\boldsymbol{b_1}\) \(\boldsymbol{b_2}\) \(\boldsymbol{b_3}\) \(\boldsymbol{E_{total}}\)
1 0.09991 0.49983 -0.70012 0.29988 0.19791 0.39712 -0.00024 -0.00041 -0.00489 0.12815


Para ser más específicos, los nuevos pesos se calculan de la siguiente manera:

Primero, para cada peso \(w_i\) se determina la derivada del error respecto a dicho peso:

\[ \frac{\partial E}{\partial w_i} \]

En el caso tradicional (por fila), se actualiza el peso de inmediato aplicando:

\[ w_i^* = w_i - \alpha \frac{\partial E}{\partial w_i}. \]

Sin embargo, bajo el enfoque Batch existe un cambio importante:
- El error total corresponde a la suma de los errores individuales de cada fila.
- Del mismo modo, las derivadas respecto al error se acumulan sumando los gradientes obtenidos en cada fila.

Por ejemplo, si consideramos el peso \(w_5\), el gradiente acumulado sería:

\[ \frac{\partial E}{\partial w_5} = \sum_{i=1}^{4} \frac{\partial E_i}{\partial w_5} \]

Finalmente, para actualizar los pesos se emplea el gradiente promedio (dividiendo entre el número total de filas, en este caso 4):

\[ w_5^* = w_5 - \alpha \cdot \frac{1}{4}\sum_{i=1}^{4}\frac{\partial E_i}{\partial w_5} \]

Por lo tanto, después de la primera época los pesos actualizados quedan:

import networkx as nx   #Permite trabajar con grafos en Python.
import matplotlib.pyplot as plt #Se usa para graficar.

G = nx.DiGraph() #Creamos un grafo dirigido


# Nodos
G.add_node('X1', pos=(0,0.7)) #Añadimos un nodo con la etiqueta X1, en la posición (0,1)
G.add_node('X2', pos=(0,0.3))
G.add_node('H1', pos=(1,0.7))
G.add_node('H2', pos=(1,0.3))
G.add_node('O1', pos=(2,0.5))

#Sesgos
G.add_node('bH', pos = (0.4, 1.1))
G.add_node('bO', pos = (1.4, 1.0))

# Aristas
Aristas = [('X1','H1'), ('X1','H2'), ('X2','H1'), ('X2','H2'), ('H1','O1'), ('H2','O1')] #Cada tupla (origen, destino) representa una flecha de entrada a salida.
G.add_edges_from(Aristas) #Añade esas conexiones al grafo.

# Aristas de sesgo
G.add_edge('bH','H1')
G.add_edge('bH','H2')
G.add_edge('bO','O1')
Aristas_Sesgos = [('bH','H1'), ('bH','H2'), ('bO','O1')]


w1 = 0.09; w2 = 0.49
w3 = -0.7; w4 = 0.39
b1 = 0; b2 = 0

w5 = 0.19; w6 = 0.4
b3 = 0

W_Ocultos = [[w1, w2],
             [w3, w4]]
W_Salida = [w5, w6]

b_Ocultos = [[b1, b2]]
b_Salida = b3

edge_labels = {
    ('X1','H1'): f"w1 = {W_Ocultos[0][0]:.2f}", #Indica que es la arista que va de la neurona X1 a H1. Usa fstring para convertir el valor del peso W_Ocultos[0][0] a un string con 1 decimal.
    ('X1','H2'): f"w2 = {W_Ocultos[0][1]:.2f}",
    ('X2','H1'): f"w3 = {W_Ocultos[1][0]:.2f}",
    ('X2','H2'): f"w4 = {W_Ocultos[1][1]:.2f}",
    ('H1','O1'): f"w5 = {W_Salida[0]:.2f}",
    ('H2','O1'): f"w6 = {W_Salida[1]:.2f}"
}

sesgos_edge_labels = {
    ('bH','H1'): r"$\mathbf{b_1=0}$",
    ('bH','H2'): r"$\mathbf{b_2=-0}$",
    ('bO','O1'): r"$\mathbf{b_3=0}$"
}


# Posiciones
pos = nx.get_node_attributes(G, 'pos') #Diccionario de posiciones de cada nodo. Se usa para dibujar la red en esas coordenadas específicas.

nodos = ['X1','X2','H1','H2','O1']

# Dibujar
nx.draw(G, pos, with_labels=True, nodelist=nodos, node_size=2000, node_color=['red','red','red','red','red'], font_weight='bold', edgecolors='black', linewidths=2, labels = {
    'X1': r'$\mathbf{X_1}$',
    'X2': r'$\mathbf{X_2}$',
    'H1': r'$\mathbf{h_1}$',
    'H2': r'$\mathbf{h_2}$',
    'O1': r'$\mathbf{O_1}$'
}); #nx.draw dibuja el grafo (pos: Coordenadas de los nodos; with_labels=True: Muestra el nombre de cada nodo; node_size: El tamaño de los nodos; node_color: El color de cada nodo)

#Dibujar sesgos
sesgos = ['bH','bO']
_ = nx.draw_networkx_nodes(
    G,
    pos,
    nodelist=sesgos,
    node_shape='^',  # triángulo
    node_color=['gray','gray'],
    node_size=1500, edgecolors='black', linewidths=2
);

sesgos_node_labels = {
    'bH': r'$\mathbf{1}$',
    'bO': r'$\mathbf{1}$'
}
_=nx.draw_networkx_labels(G, pos, labels=sesgos_node_labels, font_size=12);

_=nx.draw_networkx_edges(
    G, pos,
    edgelist=Aristas_Sesgos,          # Qué aristas dibujar
    edge_color='green',           # Color de la flecha
    arrows=True,                # Asegura que se dibujen flechas
    width=2                     # Grosor de la flecha
);

_=nx.draw_networkx_edge_labels(G, pos, edge_labels=edge_labels, label_pos=0.7, font_weight='bold', font_size = 10); #Dibuja etiquetas sobre las aristas de un grafo. edge_labels: Un diccionario que dice que mostrar en cada arista.
_ = nx.draw_networkx_edge_labels(G, pos, edge_labels=sesgos_edge_labels, font_size=10, label_pos = 0.7, font_color='green');

plt.show() #Muestra el grafo


Para la segunda, tercera y cuarta época obtenemos:

res <- Entrenar_Red(X, Y, res$W_Ocultos, res$W_Salida, res$b_Ocultos, res$b_Salida, epocas = 3, Tasa_Aprendizaje = 0.25, 2)
res$tabla %>%
  knitr::kable(
    format = "html",              
    caption = "Evolución de los pesos, sesgos y error por época",
    digits = 5,                   
    align = "c"                   
  ) %>%
  kableExtra::kable_styling(
    bootstrap_options = c("striped", "hover", "condensed", "responsive"),
    full_width = FALSE,
    position = "center"
  ) %>%
  kableExtra::row_spec(1, bold = TRUE, color = "white", background = "steelblue") %>%  
  kableExtra::column_spec(1, bold = TRUE, color = "white", background = "darkred")
Evolución de los pesos, sesgos y error por época
Epoca \(\boldsymbol{w_1}\) \(\boldsymbol{w_2}\) \(\boldsymbol{w_3}\) \(\boldsymbol{w_4}\) \(\boldsymbol{w_5}\) \(\boldsymbol{w_6}\) \(\boldsymbol{b_1}\) \(\boldsymbol{b_2}\) \(\boldsymbol{b_3}\) \(\boldsymbol{E_{total}}\)
2 0.09981 0.49966 -0.70023 0.29976 0.19586 0.39429 -0.00048 -0.00081 -0.00968 0.12800
3 0.09973 0.49950 -0.70034 0.29965 0.19386 0.39154 -0.00071 -0.00120 -0.01436 0.12786
4 0.09964 0.49934 -0.70045 0.29954 0.19190 0.38884 -0.00093 -0.00158 -0.01894 0.12773

Note que el error va disminuyendo poco a poco pero es necesario muchas más épocas para obtener el resultado deseado, o incluso un cambio en los pesos iniciales.