El perceptrón es el bloque de construcción de las redes neuronales. Calcula una suma ponderada de sus entradas, agrega un sesgo y pasa el resultado por una función de activación. En este cuaderno:
- Construimos un perceptrón desde cero con NumPy.
- Lo entrenamos con la regla clásica de aprendizaje del perceptrón para separar eventos sísmicos del ruido.
- Reutilizamos el mismo perceptrón como regresor lineal entrenado por descenso de gradiente y lo comparamos con mínimos cuadrados ordinarios (OLS, ordinary least squares).
- Estudiamos cómo la tasa de aprendizaje cambia el entrenamiento.
import numpy as np
import matplotlib.pyplot as plt
from sklearn.linear_model import LinearRegression
import mlgeo_synth1. Un perceptrón desde cero¶
Un perceptrón con entradas , pesos y peso de sesgo calcula
donde ϕ es la función de activación. Implementamos tres activaciones: ninguna (identidad), sigmoide y escalón.
class simplePerceptron:
# Activation functions
def __none(x):
return x
def __sigmoid(x):
return 1 / (1 + np.exp(-x))
def __step(x):
out = np.zeros(x.shape)
out[x >= 0] = 1
return out
possibleActivations = {
'none': __none,
'sigmoid': __sigmoid,
'step': __step
}
# Initialize
def __init__(self, w=None, b_w=None, activation=None):
if w is None:
self.w = 0
else:
self.w = w
if b_w is None:
self.b_w = 0
else:
self.b_w = b_w
if (activation is None) or (activation not in self.possibleActivations):
self.activation = 'none'
else:
self.activation = activation
def setWs(self, w, b_w):
self.w = w
self.b_w = b_w
def predict(self, x):
# x should be an (m, n) array: each row is one observation
weightedSum = np.zeros(x.shape[0])
for i in range(len(self.w)):
weightedSum += self.w[i] * x[:, i]
summed = weightedSum + (1 * self.b_w)
return self.possibleActivations[self.activation](summed)1.1 Probar el perceptrón¶
Con un peso fijado en 1, sesgo 0 y sin activación, el perceptrón debe devolver su entrada sin cambios.
ourPerceptron = simplePerceptron(w=[0], b_w=1, activation='none')
ourPerceptron.setWs([1], 0)
testInput = np.array([[100, -1, 0.1]]).T
ourPerceptron.predict(testInput)array([100. , -1. , 0.1])2. Clasificación de detecciones sísmicas¶
Los detectores sísmicos resumen ventanas cortas de un sismograma con unas pocas características (features). La tabla mlgeo_synth.detector_features contiene cuatro de esas características por ventana: la razón STA/LTA (sta_lta), la curtosis de la forma de onda, el centroide espectral en Hz y la frecuencia dominante en Hz. La etiqueta es 1 para una ventana de sismo y 0 para una ventana de ruido, con clases balanceadas.
Usamos dos características, sta_lta y spectral_centroid_hz. Los eventos tienen arribos impulsivos (STA/LTA alto) y más energía de alta frecuencia (centroide espectral alto) que el ruido de fondo.
df = mlgeo_synth.detector_features(n=2000, event_fraction=0.5, seed=0)
print(df.head())
fig, ax = plt.subplots(figsize=(6, 4.5))
for lbl, name in [(0, 'noise'), (1, 'event')]:
part = df[df['label'] == lbl]
ax.scatter(part['sta_lta'], part['spectral_centroid_hz'], s=8, alpha=0.5, label=name)
ax.set_xlabel('STA/LTA')
ax.set_ylabel('Spectral centroid (Hz)')
ax.set_title('Detector features: all 2000 windows')
ax.legend(); sta_lta kurtosis spectral_centroid_hz dominant_freq_hz label
0 10.419133 22.113184 9.556484 7.786286 1
1 3.314406 2.773164 1.716694 1.009758 0
2 2.336543 4.639630 1.271397 0.251278 0
3 3.976867 13.591792 6.692700 4.682170 1
4 1.151811 3.131239 4.474578 1.426544 0

2.1 ¿Por qué un corte fácil de dos características?¶
La regla clásica de aprendizaje del perceptrón solo converge cuando los datos son linealmente separables: alguna recta debe clasificar correctamente cada muestra de entrenamiento. Si las clases se superponen, la regla sigue actualizando para siempre. El diagrama de dispersión de arriba muestra una pequeña banda de superposición entre las dos nubes, así que el conjunto de datos completo no es linealmente separable.
Por eso seleccionamos un subconjunto casi linealmente separable: calculamos un puntaje combinado simple, sta_lta + spectral_centroid_hz, y descartamos las muestras de la banda de superposición. También nos limitamos a exactamente dos características para poder dibujar la frontera de decisión como una recta en el plano. Los datos reales de detectores son más desordenados; los cuadernos posteriores manejan la superposición con redes multicapa y pérdidas basadas en gradiente.
score = df['sta_lta'] + df['spectral_centroid_hz']
keep = ((df['label'] == 0) & (score < 5)) | ((df['label'] == 1) & (score > 9))
subset = df[keep]
# Balance the classes: 300 windows of each
rng = np.random.default_rng(42)
noise_idx = rng.choice(subset.index[subset['label'] == 0], 300, replace=False)
event_idx = rng.choice(subset.index[subset['label'] == 1], 300, replace=False)
subset = subset.loc[np.concatenate([noise_idx, event_idx])]
simpleInputs = subset[['sta_lta', 'spectral_centroid_hz']].to_numpy()
simpleOutputs = subset['label'].to_numpy()
# Shuffle the samples
p = rng.permutation(len(simpleInputs))
simpleInputs = simpleInputs[p, :]
simpleOutputs = simpleOutputs[p]
fig, ax = plt.subplots(figsize=(6, 4.5))
for lbl, name in [(0, 'noise'), (1, 'event')]:
m = simpleOutputs == lbl
ax.scatter(simpleInputs[m, 0], simpleInputs[m, 1], s=8, alpha=0.6, label=name)
ax.set_xlabel('STA/LTA')
ax.set_ylabel('Spectral centroid (Hz)')
ax.set_title('Balanced, near-linearly-separable subset (600 windows)')
ax.legend();
2.2 La regla de aprendizaje del perceptrón¶
Empezamos con todos los pesos en 0 y usamos la activación escalón, de modo que el perceptrón produce 0 o 1. Luego recorremos el conjunto de datos. Para cada muestra:
Hacemos una predicción con los pesos actuales.
Actualizamos cada peso con la regla
, donde
Aquí η es la tasa de aprendizaje, y son el objetivo y la salida para la muestra , y es la entrada de esa muestra. El peso de sesgo recibe la misma actualización con entrada 1. Note que las muestras clasificadas correctamente () dejan los pesos sin cambios.
Repetimos pasadas completas (épocas) sobre los datos hasta que una época no produzca ningún error.
Un detalle: con pesos iniciales en cero, la tasa de aprendizaje solo escala el vector de pesos final. La activación escalón ignora esa escala, así que la secuencia de predicciones, y el número de épocas hasta converger, es la misma para cualquier .
percept = simplePerceptron(w=np.zeros(2), b_w=0.0, activation='step')
learningRate = 0.01
maxEpochs = 50
mistakesPerEpoch = []
for epoch in range(maxEpochs):
mistakes = 0
for i in range(len(simpleInputs)):
thisInput = simpleInputs[i:i + 1, :]
thisTarget = simpleOutputs[i]
out = percept.predict(thisInput)[0]
error = thisTarget - out
if error != 0:
mistakes += 1
percept.setWs(percept.w + learningRate * error * thisInput[0],
percept.b_w + learningRate * error)
mistakesPerEpoch.append(mistakes)
if mistakes == 0:
break
accuracy = (percept.predict(simpleInputs) == simpleOutputs).mean()
print(f'Converged after {len(mistakesPerEpoch)} epochs; mistakes per epoch: {mistakesPerEpoch}')
print(f'Weights: {percept.w}, bias: {percept.b_w:.3f}, training accuracy: {accuracy:.3f}')Converged after 2 epochs; mistakes per epoch: [42, 0]
Weights: [0.03415388 0.03796047], bias: -0.220, training accuracy: 1.000
La regla converge en unas pocas épocas y clasifica correctamente todas las muestras de entrenamiento. Como usamos dos características, la frontera de decisión es la recta , que podemos dibujar directamente.
fig, ax = plt.subplots(figsize=(6, 4.5))
for lbl, name in [(0, 'noise'), (1, 'event')]:
m = simpleOutputs == lbl
ax.scatter(simpleInputs[m, 0], simpleInputs[m, 1], s=8, alpha=0.6, label=name)
# Decision boundary: w1*x1 + w2*x2 + b = 0 -> x2 = -(w1*x1 + b) / w2
x1 = np.linspace(simpleInputs[:, 0].min(), simpleInputs[:, 0].max(), 100)
x2 = -(percept.w[0] * x1 + percept.b_w) / percept.w[1]
ax.plot(x1, x2, 'k--', label='decision boundary')
ax.set_xlabel('STA/LTA')
ax.set_ylabel('Spectral centroid (Hz)')
ax.set_ylim(-0.5, simpleInputs[:, 1].max() + 0.5)
ax.set_title('Perceptron decision boundary')
ax.legend();
Acabamos de implementar un algoritmo en línea (online): los pesos se actualizan después de cada muestra, no después de ver el conjunto de datos completo.
Ejercicio. Sustituya spectral_centroid_hz por kurtosis en la celda de selección del subconjunto (mantenga el mismo filtro por puntaje sobre las características originales). ¿La regla de aprendizaje sigue convergiendo en 50 épocas? ¿Por qué sí o por qué no?
Solución
Normalmente no converge. El filtro por puntaje eliminó la superposición en el plano (STA/LTA, centroide espectral), pero las mismas ventanas pueden seguir superponiéndose en el plano (STA/LTA, curtosis). Cuando las clases se superponen, ninguna recta clasifica correctamente todas las muestras, así que la regla sigue cometiendo errores en cada época y mistakesPerEpoch nunca llega a cero. Para que converja habría que reseleccionar un subconjunto que sea separable en el nuevo par de características.
3. Ajustar una recta con descenso de gradiente¶
El mismo perceptrón, sin activación, es un modelo lineal . En lugar de la regla del perceptrón, podemos entrenarlo por descenso de gradiente sobre una función de costo. Generamos un conjunto de datos lineal con ruido para ajustarlo.
num = 100
xRange = [0, 5]
yRange = [0, 2.5]
rng = np.random.default_rng(1)
x = np.linspace(xRange[0], xRange[1], num=num)
y = np.linspace(yRange[0], yRange[1], num=num)
# Add uniform noise in [-0.5, 0.5]
noise_factor = 0.5
x = x + (rng.random(num) * 2 - 1) * noise_factor
y = y + (rng.random(num) * 2 - 1) * noise_factor
# Shuffle x and y together
p = rng.permutation(num)
x = x[p]
y = y[p]fig, ax = plt.subplots(figsize=(6, 4))
ax.scatter(x, y, color='k', s=12)
ax.set_xlabel('x')
ax.set_ylabel('y')
ax.set_aspect('equal')
ax.set_axisbelow(True)
ax.grid(color='gray', linestyle='dashed')
3.1 Función de costo¶
Usamos el error cuadrático medio (MSE, mean squared error) como costo.
def mseCost(prediction, target):
mse = (np.square(prediction - target)).mean()
return mse
# Sanity check: the cost of a perfect prediction is zero
mseCost(y, y)np.float64(0.0)3.2 Descenso de gradiente¶
El descenso de gradiente repite tres pasos: predecir, medir el costo y mover los pesos un paso pequeño en contra del gradiente del costo. Para el MSE y un modelo lineal, la actualización de los pesos es y la del sesgo es . Nos detenemos cuando el cambio del costo cae por debajo de una tolerancia, cuando el costo deja de ser finito (divergencia) o cuando alcanzamos el límite de iteraciones.
def gradientDescent(perceptron, costFunction, trainInput, trainTarget,
learningRate, numIterations, stoppingCriterion):
weights = []
biases = []
costs = []
previousCost = None
for i in range(numIterations):
# Run the prediction
prediction = perceptron.predict(trainInput)
# Determine the cost
thisCost = costFunction(prediction, trainTarget)
# Stop if the cost diverged
if not np.isfinite(thisCost):
break
# Stop if the change in cost is below the stopping criterion
if previousCost and np.absolute(previousCost - thisCost) <= stoppingCriterion:
break
previousCost = thisCost
# Record this weight, bias, and cost
weights.append(perceptron.w)
biases.append(perceptron.b_w)
costs.append(thisCost)
# Errors
er = np.subtract(trainTarget, prediction)
# Weight and bias updates
weightUpdate = learningRate * np.dot(trainInput.T, er)
biasWeightUpdate = learningRate * np.sum(er)
perceptron.setWs(perceptron.w + weightUpdate, perceptron.b_w + biasWeightUpdate)
return {'weights': weights, 'biases': biases, 'costs': costs}numIterations = 10000
# Initialize the weight and bias to zero
ourPerceptron.setWs([0], 0)
# Train on the first 50 points, keep the rest for testing
output = gradientDescent(ourPerceptron, mseCost, np.reshape(x[0:50], (-1, 1)),
y[0:50], 0.001, numIterations, 1e-6)# A function to plot outputs from gradient descent
def plotOutputs(gdOutput):
iterationsRan = len(gdOutput['costs'])
toPlot = ['weights', 'biases', 'costs']
fig, axes = plt.subplots(1, 3, figsize=(12, 4))
for i, key in enumerate(toPlot):
axes[i].plot(range(iterationsRan), gdOutput[key], color='r')
axes[i].set_xlabel('Iteration #')
axes[i].set_ylabel('Value')
axes[i].set_title(key.capitalize())
axes[i].set_axisbelow(True)
axes[i].xaxis.grid(color='gray', linestyle='dashed')
fig.tight_layout()
return fig
plotOutputs(output);
# Try the perceptron on data it has not seen before
testPredictions = ourPerceptron.predict(np.reshape(x[50:100], (-1, 1)))
testCost = mseCost(testPredictions, np.reshape(y[50:100], (-1, 1)))
'The mean squared error of our perceptron on the test half is: %f' % testCost'The mean squared error of our perceptron on the test half is: 1.122684'3.3 Comparación con mínimos cuadrados ordinarios¶
OLS resuelve el mismo problema en forma cerrada. Un perceptrón entrenado por descenso de gradiente que funcione debe llegar casi a la misma recta.
def compareOutputs(x, y, perceptron, xRange):
fig, ax = plt.subplots(figsize=(12, 4))
ax.scatter(x, y, color='k', s=12)
ax.set_aspect('equal')
forLine = np.linspace(xRange[0], xRange[1])
ax.plot(forLine, perceptron.predict(np.reshape(forLine, (-1, 1))),
color='r', linestyle='dotted', linewidth=1.5)
# How well do we do relative to OLS?
OLSoutput = LinearRegression().fit(x[0:50].reshape(-1, 1), y[0:50])
ax.plot(forLine, OLSoutput.predict(forLine.reshape(-1, 1)),
color='b', linestyle='dashed', linewidth=1.5)
ax.set_xlabel('x')
ax.set_ylabel('y')
ax.set_axisbelow(True)
ax.grid(color='gray', linestyle='dashed')
ax.legend(['Data', 'Perceptron fit', 'OLS fit'])
return fig
compareOutputs(x, y, ourPerceptron, xRange);
4. Cómo la tasa de aprendizaje cambia el entrenamiento¶
La tasa de aprendizaje η controla el tamaño del paso del descenso de gradiente. Demasiado pequeña, y el entrenamiento se arrastra; demasiado grande, y el costo oscila o diverge. La cuadrícula siguiente entrena el mismo perceptrón desde el mismo inicio en cero con cuatro tasas de aprendizaje y grafica la curva de costo de cada una (escala logarítmica en el eje del costo).
learningRates = [1e-5, 1e-4, 1e-3, 1e-2]
fig, axes = plt.subplots(2, 2, figsize=(10, 7))
with np.errstate(over='ignore', invalid='ignore'):
for ax, lr in zip(axes.ravel(), learningRates):
gdPerceptron = simplePerceptron(w=[0], b_w=0, activation='none')
gdOut = gradientDescent(gdPerceptron, mseCost, np.reshape(x[0:50], (-1, 1)),
y[0:50], lr, 2000, 1e-8)
costs = np.array(gdOut['costs'])
# Truncate the curve once the cost exceeds 1e12 (divergence)
plotCosts = costs[costs < 1e12]
ax.semilogy(plotCosts, color='r', label=f'final cost = {costs[-1]:.3g}')
ax.set_xlabel('Iteration #')
ax.set_ylabel('MSE cost')
ax.set_title(f'learning rate = {lr:g}')
ax.legend()
ax.set_axisbelow(True)
ax.grid(color='gray', linestyle='dashed')
fig.tight_layout()
Lea los cuatro paneles: con el costo sigue bajando después de 2000 iteraciones (demasiado lento). Con converge, pero toma más de mil iteraciones. Con converge en unos cientos de iteraciones. Con el costo crece sin límite: los pasos sobrepasan el mínimo y el entrenamiento diverge (la curva se detiene donde el costo se vuelve infinito).
4.1 Explorador interactivo¶
El widget siguiente le permite variar la tasa de aprendizaje, el número de iteraciones, el criterio de parada y los pesos iniciales, y luego reentrenar y volver a graficar. Los widgets no funcionan en la versión estática del libro: descargue y ejecute este cuaderno en Jupyter para usarlo. La cuadrícula estática de arriba muestra la misma lección.
import ipywidgets as widgets
from IPython.display import display, clear_output
%matplotlib inline# Create inputs
learningRateSlider = widgets.FloatLogSlider(
value=.001,
min=-6,
max=1.0,
step=1,
description='Learning Rate:'
)
iterationsSlider = widgets.Dropdown(
options=[1, 10, 100, 1000, 10000, 100000],
value=100,
description='Number of Iterations:'
)
stoppingCriterionSlider = widgets.FloatLogSlider(
value=1e-4,
min=-8,
max=1,
step=1,
description='Stopping Criterion:'
)
startingWeight = widgets.BoundedFloatText(
value=0,
min=-50,
max=50,
description='Weight'
)
startingBias = widgets.BoundedFloatText(
value=0,
min=-50,
max=5000,
description='Bias'
)
# Create the "Update" button
updateBtn = widgets.Button(description="Update")
outputWidget = widgets.Output()
# Define the function to run when the "Update" button is clicked
def updateClick(_):
with outputWidget:
clear_output(wait=True)
learningRate = learningRateSlider.value
numIterations = iterationsSlider.value
stoppingCriterion = stoppingCriterionSlider.value
ourPerceptron.setWs([startingWeight.value], startingBias.value)
output = gradientDescent(ourPerceptron, mseCost, np.reshape(x[0:50], (-1, 1)),
y[0:50], learningRate, numIterations, stoppingCriterion)
display(compareOutputs(x, y, ourPerceptron, xRange))
display(plotOutputs(output))
# Set the function to be called when the button is clicked
updateBtn.on_click(updateClick)
# Display the widgets
display(learningRateSlider, iterationsSlider, stoppingCriterionSlider,
startingWeight, startingBias, updateBtn, outputWidget)Resumen¶
- Un perceptrón es una suma ponderada más un sesgo, pasada por una función de activación.
- La regla clásica de aprendizaje del perceptrón converge solo con datos linealmente separables. Separó nuestras ventanas de evento y de ruido usando dos características del detector, y la elección de dos características nos permitió dibujar la frontera de decisión.
- Sin activación, el mismo perceptrón es un regresor lineal. El descenso de gradiente sobre el costo MSE recupera casi la misma recta que OLS.
- La tasa de aprendizaje fija el compromiso entre convergencia lenta y divergencia.
Siguiente: 4.1 Redes neuronales apila perceptrones en capas para manejar datos que una sola recta no puede separar.