Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

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:

  1. Construimos un perceptrón desde cero con NumPy.
  2. Lo entrenamos con la regla clásica de aprendizaje del perceptrón para separar eventos sísmicos del ruido.
  3. 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).
  4. Estudiamos cómo la tasa de aprendizaje cambia el entrenamiento.

🖥️ Diapositivas — Sesión 21 (mié 18 nov)

import numpy as np
import matplotlib.pyplot as plt
from sklearn.linear_model import LinearRegression

import mlgeo_synth

1. Un perceptrón desde cero

Un perceptrón con entradas x1,,xnx_1, \dots, x_n, pesos w1,,wnw_1, \dots, w_n y peso de sesgo bb calcula

o=ϕ(jwjxj+b),o = \phi\left(\sum_j w_j x_j + b\right),

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
<Figure size 600x450 with 1 Axes>

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();
<Figure size 600x450 with 1 Axes>

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:

  1. Hacemos una predicción con los pesos actuales.

  2. Actualizamos cada peso con la regla

    wj=wj+Δwjw_j = w_j + \Delta w_j, donde Δwj=η(tioi)xji\Delta w_j = \eta\,(t^i - o^i)\,x^i_j

    Aquí η es la tasa de aprendizaje, tit^i y oio^i son el objetivo y la salida para la muestra ii, y xjix^i_j es la entrada jj de esa muestra. El peso de sesgo recibe la misma actualización con entrada 1. Note que las muestras clasificadas correctamente (ti=oit^i = o^i) dejan los pesos sin cambios.

  3. 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 η>0\eta > 0.

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 w1x1+w2x2+b=0w_1 x_1 + w_2 x_2 + b = 0, 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();
<Figure size 600x450 with 1 Axes>

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?

3. Ajustar una recta con descenso de gradiente

El mismo perceptrón, sin activación, es un modelo lineal o=wx+bo = w x + b. 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')
<Figure size 600x400 with 1 Axes>

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 Δw=ηXT(to)\Delta w = \eta\, X^T (t - o) y la del sesgo es Δb=ηi(tioi)\Delta b = \eta \sum_i (t^i - o^i). 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);
<Figure size 1200x400 with 3 Axes>
# 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);
<Figure size 1200x400 with 1 Axes>

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()
<Figure size 1000x700 with 4 Axes>

Lea los cuatro paneles: con η=105\eta = 10^{-5} el costo sigue bajando después de 2000 iteraciones (demasiado lento). Con η=104\eta = 10^{-4} converge, pero toma más de mil iteraciones. Con η=103\eta = 10^{-3} converge en unos cientos de iteraciones. Con η=102\eta = 10^{-2} 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)
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...

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.