Python в аудио-спецэффектах: как работают нейросети изнутри

- -
- 100%
- +
Сейчас этот результат вызывает улыбку. Современные модели достигают точности, близкой к ста процентам, на гораздо более сложных задачах. Но то чувство — чувство, когда твоё творение учится, — остаётся неизменным. Я надеюсь, вы испытаете его в этой книге. Когда ваша первая нейросеть впервые правильно определит эмоцию в голосе или уберёт шум из записи — вы поймёте, о чём я говорю.
Глава 1. От нейрона к сети: учим компьютер слышать
«Нейросеть — не чёрный ящик. Это конструктор. Ты можешь собрать его сам»
О чём эта глава
Всё, что мы будем делать в этой книге, строится на одном фундаменте: искусственных нейронных сетях. Шумоподавление, разделение источников, синтез речи, детектор эмоций — все эти впечатляющие технологии начинаются с одной простой идеи. С идеи о том, что компьютер может учиться на примерах. Не выполнять заранее написанные инструкции, а самостоятельно находить закономерности в данных.
В этой главе мы начнём с самого простого — с одного искусственного нейрона. Мы поймём, как он работает, как принимает решения и, главное, как он учится. Мы напишем нейрон на чистом Python, без единой строчки из библиотек глубокого обучения. Мы научим его различать громкий и тихий звук — задача простая, но она даст нам понимание механики обучения.
Затем мы соберём из нейронов полносвязную сеть. Мы узнаем, что такое скрытые слои, функции активации и обратное распространение ошибки. Мы познакомимся с PyTorch — фреймворком, который станет нашим основным инструментом на всю книгу. Мы напишем первую настоящую нейросеть, которая будет классифицировать аудиофрагменты — отличать речь от музыки, а музыку от шума.
К концу этой главы у вас будет работающая модель классификации звуков и, что важнее, понимание того, как она устроена внутри. Не магия. Математика и код.
Зачем нейросети звук
Прежде чем погружаться в нейроны и слои, давайте ответим на простой вопрос: зачем вообще применять нейросети к звуку? У нас уже есть классические алгоритмы. Фильтры. Свёртка. Спектральное вычитание. Они работают. Они понятны. Они не требуют обучающих данных и видеокарт. Зачем усложнять?
Представьте, что вы хотите написать программу, которая отличает лай собаки от плача ребёнка. С помощью классических алгоритмов вы бы пошли примерно таким путём. Вы бы записали десятки примеров лая и плача. Вы бы посмотрели на их спектрограммы. Вы бы заметили, что у лая есть характерные паттерны — короткие всплески в среднем диапазоне частот, у плача — более продолжительные звуки с модуляцией высоты. Вы бы придумали правила: если средняя частота выше X и длительность меньше Y, то это лай. Если есть периодическая модуляция с частотой Z, то это плач.
Этот подход работает. Но у него есть проблемы. Каждый новый звук требует нового набора правил. Плач одного ребёнка может сильно отличаться от плача другого. Лай таксы не похож на лай овчарки. Шум дождя можно спутать с шипением радио. Вам придётся придумывать и настраивать правила для каждого случая, и в какой-то момент сложность станет неуправляемой.
Нейросеть решает эту проблему иначе. Вы не придумываете правила. Вы собираете dataset — набор примеров: тысячу записей лая, тысячу записей плача, тысячу записей шума. Вы показываете их нейросети, и она сама находит закономерности, которые отличают один класс от другого. Ей не нужны формулы для средней частоты и длительности. Она сама выяснит, какие признаки важны, а какие нет.
Более того, нейросеть может найти закономерности, которые человек вообще не замечает. Мелкие детали в фазовом спектре. Тонкие корреляции между далёкими частотами. Паттерны, которые не видны на спектрограмме невооружённым глазом. Именно поэтому нейросетевое шумоподавление работает лучше классического, а нейросетевой синтез речи звучит естественнее, чем механическая сборка из фонем.
Один нейрон: модель, которая учится
Давайте начнём с самого простого строительного блока нейросети — искусственного нейрона. Биологический нейрон получает сигналы от других нейронов через дендриты, обрабатывает их в теле клетки и, если суммарный сигнал достаточно силён, посылает импульс по аксону. Искусственный нейрон работает похоже, но проще.
Искусственный нейрон получает на вход несколько чисел. Он умножает каждое число на свой вес — параметр, который определяет важность этого входа. Затем он суммирует все взвешенные входы, добавляет смещение — ещё один параметр — и применяет к результату функцию активации. Функция активации решает, насколько сильным будет выход нейрона. Если взвешенная сумма большая и положительная — выход близок к единице. Если маленькая или отрицательная — к нулю.
Формула нейрона предельно проста:
y = f(w1·x1 + w2·x2 + ... + wn·xn + b)
где x1, x2 — входы, w1, w2 — веса, b — смещение, f — функция активации, y — выход.
Веса и смещение — это то, что нейрон настраивает в процессе обучения. В начале они случайные, и нейрон выдаёт бессмысленный результат. Но у нас есть обучающие примеры — входы и правильные ответы для них. Мы подаём вход, смотрим на выход нейрона, сравниваем с правильным ответом и вычисляем ошибку. Затем мы чуть-чуть меняем веса так, чтобы ошибка уменьшилась. Повторяем тысячи раз. Постепенно нейрон учится давать правильные ответы.
Пишем нейрон на Python
Давайте реализуем эту идею в коде. Мы создадим класс для одного нейрона и научим его решать простейшую задачу: определять, громкий звук или тихий.
python
import numpy as np
class SingleNeuron:
"""
Один искусственный нейрон.
Принимает один входной признак и выдаёт бинарное предсказание.
"""
def __init__(self):
# Инициализируем вес и смещение случайными малыми значениями
self.weight = np.random.randn() * 0.1
self.bias = np.random.randn() * 0.1
def sigmoid(self, x):
"""Сигмоидная функция активации: превращает любое число в значение от 0 до 1."""
return 1.0 / (1.0 + np.exp(-x))
def forward(self, x):
"""
Прямой проход: вычисляет выход нейрона по входу x.
x — одно число (например, средняя громкость аудиофрагмента).
"""
z = self.weight * x + self.bias
return self.sigmoid(z)
def train_step(self, x, y_true, learning_rate=0.1):
"""
Один шаг обучения.
x — входное значение
y_true — правильный ответ (0 или 1)
learning_rate — скорость обучения
"""
# Прямой проход
y_pred = self.forward(x)
# Вычисляем ошибку (разница между предсказанием и истиной)
error = y_pred - y_true
# Вычисляем градиенты (насколько нужно изменить параметры)
# Для сигмоиды: производная = y_pred * (1 - y_pred)
d_weight = error * y_pred * (1 - y_pred) * x
d_bias = error * y_pred * (1 - y_pred)
# Обновляем параметры
self.weight -= learning_rate * d_weight
self.bias -= learning_rate * d_bias
return error
# Создаём нейрон
neuron = SingleNeuron()
# Готовим обучающие данные
# Представим, что мы измерили среднюю громкость нескольких аудиофрагментов
# Значения около 0.8 — громкий звук (метка 1)
# Значения около 0.2 — тихий звук (метка 0)
train_data = [
(0.15, 0.0), (0.22, 0.0), (0.18, 0.0), (0.25, 0.0), (0.10, 0.0), # тихие
(0.75, 1.0), (0.82, 1.0), (0.70, 1.0), (0.88, 1.0), (0.79, 1.0), # громкие
]
print("Обучение одного нейрона:")
print("Эпоха | Вес | Смещение | Пример | Предсказание | Истина | Ошибка")
print("-" * 70)
# Обучаем нейрон
for epoch in range(200):
total_error = 0
for x, y_true in train_data:
y_pred = neuron.forward(x)
error = neuron.train_step(x, y_true, learning_rate=0.5)
total_error += abs(error)
if epoch % 50 == 0 or epoch < 10:
x_example, y_example = train_data[0]
y_pred_example = neuron.forward(x_example)
print(f"{epoch:5d} | {neuron.weight:+.3f} | {neuron.bias:+.3f} | "
f"{x_example:.2f} | {y_pred_example:.4f} | {y_example:.1f} | "
f"{abs(y_pred_example - y_example):.4f}")
# Проверяем обученный нейрон
print("\nРезультаты после обучения:")
test_data = [0.12, 0.30, 0.65, 0.90, 0.20, 0.85]
for x in test_data:
pred = neuron.forward(x)
label = "ГРОМКИЙ" if pred > 0.5 else "ТИХИЙ"
print(f" Громкость: {x:.2f} -> {pred:.4f} ({label})")
Запустите этот код. Вы увидите, как в начале обучения нейрон выдаёт случайные предсказания — примерно 0.5 для любого входа. Постепенно вес и смещение подстраиваются. К концу обучения нейрон уверенно отличает тихие звуки от громких: для значений около 0.2 предсказание близко к нулю, для значений около 0.8 — близко к единице.
Разберём ключевые моменты. Сигмоидная функция активации превращает любое число в значение между нулём и единицей. Большое положительное число на входе даёт значение, близкое к единице. Большое отрицательное — близкое к нулю. Это удобно для бинарной классификации: мы интерпретируем выход как вероятность того, что вход принадлежит классу «1».
Обучение происходит через корректировку веса и смещения. Мы вычисляем ошибку — разницу между предсказанием и правильным ответом. Затем вычисляем градиенты — насколько нужно изменить параметры, чтобы ошибка уменьшилась. И делаем маленький шаг в направлении, противоположном градиенту. Это называется градиентным спуском.
От нейрона к сети
Один нейрон может решать только очень простые задачи — разделять данные прямой линией. В реальном мире границы между классами гораздо сложнее. Граница между речью и музыкой, между эмоцией радости и эмоцией грусти, между чистым голосом и зашумлённым — всё это нелинейные, запутанные границы, которые один нейрон не может описать.
Решение — объединить нейроны в сеть. В полносвязной сети нейроны организованы в слои. Каждый нейрон первого слоя получает входные данные. Каждый нейрон второго слоя получает выходы всех нейронов первого слоя. Каждый нейрон третьего — выходы второго. И так далее. Между слоями применяются нелинейные функции активации, и именно эти нелинейности позволяют сети описывать сложные границы.
Обучение сети происходит так же, как обучение одного нейрона, но с одним усложнением: ошибку нужно распространить от выходного слоя к входному через все промежуточные слои. Это называется обратным распространением ошибки. Мы вычисляем, как ошибка на выходе зависит от каждого веса в сети, и обновляем все веса одновременно в направлении уменьшения ошибки.
Классификация звуков: речь, музыка, шум
Давайте применим полносвязную сеть к реальной задаче классификации звуков. Мы научим сеть отличать речь от музыки и шума по набору признаков, извлечённых из аудиофрагментов. Для этого мы воспользуемся PyTorch — фреймворком, который берёт на себя вычисление градиентов и обновление параметров.
python
import torch
import torch.nn as nn
import torch.optim as optim
import librosa
import numpy as np
def extract_features(y, sr, frame_size=2048, hop_length=512):
"""
Извлекает признаки из аудиосигнала для подачи в нейросеть.
Возвращает список векторов признаков для каждого фрейма.
"""
features = []
# Разбиваем на фреймы
num_frames = (len(y) - frame_size) // hop_length + 1
for i in range(num_frames):
start = i * hop_length
frame = y[start:start + frame_size]
# Признак 1: RMS энергия (средняя громкость)
rms = np.sqrt(np.mean(frame ** 2))
# Признак 2: Zero-crossing rate (частота пересечений нуля)
zcr = np.sum(np.abs(np.diff(np.sign(frame)))) / (2 * len(frame))
# Признак 3: Спектральный центроид (центр тяжести спектра)
spectrum = np.abs(np.fft.fft(frame * np.hamming(len(frame))))
freqs = np.fft.fftfreq(len(frame), 1 / sr)
positive_freqs = freqs[:len(freqs)//2]
positive_spectrum = spectrum[:len(spectrum)//2]
if np.sum(positive_spectrum) > 0:
centroid = np.sum(positive_freqs * positive_spectrum) / np.sum(positive_spectrum)
else:
centroid = 0
# Признак 4: Спектральный спад (частота, ниже которой 85% энергии)
cumsum = np.cumsum(positive_spectrum ** 2)
if cumsum[-1] > 0:
rolloff_idx = np.where(cumsum >= 0.85 * cumsum[-1])[0][0]
rolloff = positive_freqs[rolloff_idx]
else:
rolloff = 0
features.append([rms, zcr, centroid / 1000, rolloff / 1000])
return np.array(features, dtype=np.float32)
class SoundClassifier(nn.Module):
"""
Полносвязная сеть для классификации звуков.
4 входных признака -> 16 скрытых нейронов -> 8 скрытых нейронов -> 3 класса
"""
def __init__(self):
super().__init__()
self.fc1 = nn.Linear(4, 16) # входной слой -> скрытый слой 1
self.fc2 = nn.Linear(16, 8) # скрытый слой 1 -> скрытый слой 2
self.fc3 = nn.Linear(8, 3) # скрытый слой 2 -> выходной слой (3 класса)
self.relu = nn.ReLU() # функция активации ReLU
self.softmax = nn.Softmax(dim=1) # для получения вероятностей
def forward(self, x):
x = self.relu(self.fc1(x))
x = self.relu(self.fc2(x))
x = self.softmax(self.fc3(x))
return x
# Создаём модель, функцию потерь и оптимизатор
model = SoundClassifier()
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)
print("Модель создана:")
print(model)
print(f"\nКоличество параметров: {sum(p.numel() for p in model.parameters())}")
Наша сеть принимает четыре признака для каждого аудиофрейма: среднюю громкость, частоту пересечений нуля, спектральный центроид и спектральный спад. Частота пересечений нуля показывает, насколько сигнал колеблется — у шума она высокая, у музыки средняя, у речи низкая. Спектральный центроид показывает, где сосредоточена энергия спектра — у шипящих звуков он высокий, у басовых — низкий. Спектральный спад показывает, насколько спектр сконцентрирован — у тональных звуков он низкий, у шумовых — высокий.
Сеть состоит из трёх слоёв. Первый слой принимает четыре признака и выдаёт шестнадцать скрытых представлений. Второй сжимает их до восьми. Третий выдаёт три числа — вероятности для каждого класса: речь, музыка, шум. Функция активации ReLU оставляет положительные значения без изменений и обнуляет отрицательные — это простая нелинейность, которая хорошо работает на практике.
Обучение классификатора
Теперь нам нужны данные для обучения. В идеале — тысячи размеченных аудиофрагментов. Для демонстрации мы создадим синтетические данные: сгенерируем признаки, похожие на те, что бывают у реальной речи, музыки и шума.
python
def generate_synthetic_data(n_samples=500):
"""
Генерирует синтетические данные для демонстрации обучения.
В реальном проекте здесь была бы загрузка реальных аудиофайлов.
"""
np.random.seed(42)
data = []
labels = []
for _ in range(n_samples):
# Класс 0: Речь
data.append([
np.random.normal(0.3, 0.1), # rms: умеренная громкость
np.random.normal(0.15, 0.05), # zcr: низкая частота пересечений нуля
np.random.normal(0.8, 0.2), # centroid: низкий центроид
np.random.normal(0.6, 0.3), # rolloff: умеренный спад
])
labels.append(0)
# Класс 1: Музыка
data.append([
np.random.normal(0.4, 0.15), # rms: средняя громкость
np.random.normal(0.25, 0.08), # zcr: средняя
np.random.normal(1.5, 0.5), # centroid: средний
np.random.normal(2.0, 1.0), # rolloff: выше
])
labels.append(1)
# Класс 2: Шум
data.append([
np.random.normal(0.2, 0.1), # rms: низкая громкость
np.random.normal(0.45, 0.1), # zcr: высокая
np.random.normal(2.5, 1.0), # centroid: высокий
np.random.normal(3.0, 1.5), # rolloff: высокий
])
labels.append(2)
# Перемешиваем
data = np.array(data, dtype=np.float32)
labels = np.array(labels)
shuffle_idx = np.random.permutation(len(data))
return data[shuffle_idx], labels[shuffle_idx]
# Генерируем данные
X, y = generate_synthetic_data(500)
# Разделяем на обучающую и тестовую выборки
split = int(0.8 * len(X))
X_train, X_test = X[:split], X[split:]
y_train, y_test = y[:split], y[split:]
# Преобразуем в тензоры PyTorch
X_train_tensor = torch.tensor(X_train)
y_train_tensor = torch.tensor(y_train, dtype=torch.long)
X_test_tensor = torch.tensor(X_test)
y_test_tensor = torch.tensor(y_test, dtype=torch.long)
print(f"Обучающих примеров: {len(X_train)}")
print(f"Тестовых примеров: {len(X_test)}")
# Обучаем модель
model = SoundClassifier()
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.01)
num_epochs = 200
for epoch in range(num_epochs):
# Прямой проход
outputs = model(X_train_tensor)
loss = criterion(outputs, y_train_tensor)
# Обратный проход и оптимизация
optimizer.zero_grad()
loss.backward()
optimizer.step()
if epoch % 40 == 0 or epoch == num_epochs - 1:
# Оцениваем точность на тестовой выборке
with torch.no_grad():
test_outputs = model(X_test_tensor)
_, predicted = torch.max(test_outputs, 1)
accuracy = (predicted == y_test_tensor).float().mean()
print(f"Эпоха {epoch:3d}: Потери = {loss.item():.4f}, Точность = {accuracy:.2%}")
print("\nОбучение завершено!")
Запустите этот код. Вы увидите, как потери уменьшаются от эпохи к эпохе, а точность на тестовой выборке растёт. К концу обучения модель должна достигнуть точности выше девяноста процентов — это означает, что она хорошо научилась отличать три класса звуков друг от друга.
Разберём, что происходит при обучении. criterion = nn.CrossEntropyLoss() — функция потерь, которая измеряет, насколько предсказания модели отличаются от правильных ответов. optimizer = optim.Adam(model.parameters(), lr=0.01) — оптимизатор Adam, который сам подбирает величину шага для каждого параметра. loss.backward() — магия PyTorch: автоматически вычисляет градиенты для всех параметров модели. optimizer.step() — обновляет параметры в направлении уменьшения ошибки.
Применяем модель к реальному звуку
Теперь давайте проверим нашу модель на реальном аудиофайле. Мы загрузим запись, извлечём признаки и посмотрим, какие классы модель предсказывает для разных фреймов.
python
def classify_audio_file(filepath, model, label_names):
"""Классифицирует аудиофайл по фреймам."""
y, sr = librosa.load(filepath, sr=22050, mono=True)
features = extract_features(y, sr)
# Преобразуем в тензор
features_tensor = torch.tensor(features)
# Предсказываем
with torch.no_grad():
outputs = model(features_tensor)
_, predicted = torch.max(outputs, 1)
# Считаем статистику
unique, counts = np.unique(predicted.numpy(), return_counts=True)
total = len(predicted)
print(f"\nАнализ файла: {filepath}")
print(f" Всего фреймов: {total}")
for cls, count in zip(unique, counts):
print(f" {label_names[cls]}: {count} фреймов ({count / total:.1%})")
return predicted.numpy()
# Классифицируем тестовый файл (если есть) или создадим синтетический
label_names = ['Речь', 'Музыка', 'Шум']
# Создадим тестовый сигнал: первые 2 секунды — тон (музыка), затем шум, затем речь из файла
sr = 22050
t = np.linspace(0, 4.0, int(sr * 4.0), endpoint=False)
test_signal = np.zeros_like(t)
# Музыка: синусоида с обертонами
test_signal[:int(1.5 * sr)] = (np.sin(2 * np.pi * 440 * t[:int(1.5 * sr)]) +
0.5 * np.sin(2 * np.pi * 880 * t[:int(1.5 * sr)]))
# Шум
test_signal[int(1.5 * sr):int(3.0 * sr)] = np.random.randn(int(1.5 * sr)) * 0.3
# Речь (если есть файл) или ещё один тон другой частоты
try:
speech, _ = librosa.load('voice_sample.wav', sr=sr, mono=True)
test_signal[int(3.0 * sr):] = speech[:len(test_signal) - int(3.0 * sr)]
except:
test_signal[int(3.0 * sr):] = np.sin(2 * np.pi * 220 * t[int(3.0 * sr):])
import soundfile as sf
sf.write('test_mixed.wav', test_signal, sr)
print("Создан test_mixed.wav — смесь музыки, шума и речи")
# Классифицируем
predictions = classify_audio_file('test_mixed.wav', model, label_names)
Запустите код и посмотрите на результат. Модель должна правильно определить: первые фреймы — музыка, средние — шум, последние — речь. Это базовая классификация, но принцип, который мы здесь использовали, лежит в основе всех нейросетевых систем обработки звука.
За кулисами: история перцептрона
Первый искусственный нейрон — перцептрон — был предложен Фрэнком Розенблаттом в 1958 году. Это было аналоговое устройство размером с комнату, которое использовало резисторы и потенциометры для настройки весов. Розенблатт верил, что перцептроны станут основой искусственного интеллекта.
В 1969 году Марвин Минский и Сеймур Пейперт опубликовали книгу, в которой математически доказали, что однослойный перцептрон не может решить даже простейшую логическую задачу XOR — исключающее ИЛИ. Это доказательство охладило интерес к нейросетям на десятилетие — период, известный как «зима искусственного интеллекта».
Возрождение началось в 1980-х, когда Джеффри Хинтон и другие исследователи показали, что многослойные сети с нелинейными функциями активации способны решать XOR и множество других задач. Ключевым стало изобретение алгоритма обратного распространения ошибки, который позволял эффективно обучать глубокие сети. Сегодня тот самый XOR, который убил перцептрон, решается сетью из трёх нейронов за доли секунды.
Лаборатория ошибок
Модель не обучается, потери не уменьшаются. Проверьте скорость обучения. Слишком большая — модель «перепрыгивает» минимум и расходится. Слишком маленькая — обучение идёт слишком медленно. Попробуйте значения 0.1, 0.01, 0.001. Для Adam хороший стартовый диапазон — 0.001–0.0001.
Модель отлично работает на обучающих данных, но плохо на тестовых. Это переобучение. Модель запомнила обучающие примеры вместо того, чтобы выучить общие закономерности. Решения: уменьшить количество нейронов, добавить регуляризацию, увеличить количество обучающих данных.
Точность на одном классе высокая, на других — низкая. Данные несбалансированы. Если речевых примеров в десять раз больше, чем шумовых, модель будет предсказывать речь чаще. Решение: сбалансировать выборку или использовать взвешенную функцию потерь.



