Пример динамики спектра радиосигналов, источник: arXiv:2005.06068v1.

ВВЕДЕНИЕ

До недавнего времени одним из популярных классических алгоритмов распознавания модуляций является метод, основанный на статистической обработке кумулянтов и гипотезе максимального правдоподобия. Использование такого метода достаточно трудоемко и требует достаточно глубоких экспертных знаний в предметной области.

В далеком 2016 году пионер применения методов машинного обучения в обработке радиосигналов Timothy J. O’Shea предложил нейронную сеть (НС) с использованием двумерных сверточных слоев [1], которые хорошо себя показали при обработке изображений. В своей статье [2] O’Shea утверждает, что такая НС может конкурировать с классическими методами автоматического распознавания модуляции (АРМ), особенно при низком отношении сигнал/шум (SNR). После получения некоторого опыта в работе с нейронками захотелось проверить заявленную возможность классификации таких сигналов.

Применение машинного обучения с использованием НС позволяет производить классификацию видов модуляции радиосигналов без глубоких знаний в области DSP. Для создания классификатора необходимо сформировать массив данных (Dataset) обучения, в качестве которых используем сырые синфазные и квадратурные I/Q отсчеты радиосигнала. Далее следует подобрать архитектуру сети и провести обучение. Результатом обучения будет файл полученных весов и файл описания архитектуры сети. При обучении сети обычно используют машину с большой вычислительной мощностью, а полученный результат обучения можно применять на простой машине. 

Попробуем проверить подход глубокого обучения на соответствие минимальной жизнеспособности классификации для девяти цифровых видов модуляции: QPSK, OQPSK, P\4QPSK, QAM-16, QAM-32, QAM-64, QAM-128, QAM256 и QAM512.   

Статья состоит из следующих основных частей:

1. Создание массива данных Dataset

2. Архитектура нейронной сети

3. Обучение, тестирование, эксперименты

4. Выводы

1. Создание Dataset

Авторский Dataset RADIOML 2016.10A [3] состоял из 8 цифровых и 3 аналоговых нормированных сигналов с шагом уровня SNR в 1 dB. Наш массив данных будет состоять только из 9 цифровых сигналов, без предварительного нормирования, по 20 файлов каждого вида модуляции, с различной символьной скоростью, частотой дискретизации, разным уровнем шума и размером примерно по 1 Мбайт.

Для этого использовались следующие источники:

  • векторный генератор,

  • реальные записи сигналов с разным уровнем SNR,

  • специальная программа синтеза I/Q сигналов.

Приёмная часть состояла из конвектора в ПЧ 140 МГц и модуля ADC+FPGA, выходной формат данных 2 по 16 бит на один I/Q отсчет. Для создания обучающего массива и устранения инвариантности сдвига применялась аугментация в виде нарезки семплов размером 2 х 128 с шагом в один отсчет и сдвигом начала в файле на 8192 байт. Увеличение размера семпла более 2 х 128 не дало значимого прироста в точности.  

В качестве проверочных данных будет использоваться массив от начала до 8192 байта, и в обучении он не будет использоваться. Суммарный размер Dataset получился X =(5400000,2,128) семплов или по 600000 на каждый вид модуляции. Массив меток Y =(5400000,9) сформирован в формате ohe (One Hot Encoder) при помощи утилиты to_categoricall от Keras.

#  загружаем необходимые библиотеки 
import pandas as pd
from tensorflow.keras import utils  #Используем для to_categoricall
import numpy as np                  #Библиотека работы с массивами
import os                           #Для работы с файлами 
from sklearn.model_selection import train_test_split    # добавляем библиотеку для разбивки 

# АУГМЕНТАЦИЯ, делаем нарезку шагом 1 отсчет, семплы по 128 отсчетов 
Mode_Type9 = ["QPSK","OQPSK","P4QPSK","QAM16","QAM32","QAM64","QAM128","QAM256","QAM512"] 
X_ds = []  #  массив X
Y_ds = []  # метки Y
for i in range(len(Mode_Type9)):    #проходим по всем типам модуляции
    md = Mode_Type9[i]              #берём текущий тип модуляции
    for filename in os.listdir("D:/ASD/"+ md):  #проходим по файлам папки
        name = "D:/ASD/"+md+"/"+filename        # получаем полный путь к файлу
        dtt = np.fromfile(name, dtype = np.int16, count = 98304,offset = 8192).astype(np.float16) # считываем count  
        for n in range(0, 120000, 4):  # берем с шагом по 4 байт – это 1 отсчетI\Q
            dd1 = np.frombuffer(dtt, dtype = np.float16, count = 256, offset = n) # считываем по 1 семплу
            X_ds.append(np.reshape(dd1, (-1,2)).T) # расщепляем на I и Q, транспанируем-Т и кидаем в массив X_ds
            Y_ds.append(to_categorical(i, len(Mode_Type9))) #  кидаем в массив меток Y_ds
X_ds = np.array(X_ds)          # переводим в нампи массив 
Y_ds = np.array(Y_ds)
print(X_ds.shape)              # проверяем формат массива Х= (5400000, 2, 128) 
print(Y_ds.shape)              # проверяем формат меток Y= (5400000, 9) 

Далее при помощи утилиты train_test_split от Scikit-learn разбиваем полученный массив на train и test выборки в пропорции 70 на 30, добавляем размерность и сохраняем в NumPy массив. Такая разбивка не дает точно сбалансированные массивы по отдельным классам и незначительная разбалансировка не повлияла на точность.    

# test_size=0.3 – для теста будет выделено 30% от тренировочных данных  
# shuffle=True - перемешать данные
# x_train - данные для обучения
# x_test - данные для проверки
# y_train - правильные ответы – метки для обучения, формат OHE
# y_test - правильные ответы – метки для проверки, формат OHE
x_train, x_test, y_train, y_test = train_test_split(X_ds, Y_ds, test_size=0.3, shuffle=True)    
print (x_train.shape)    # (3780000, 2, 128) проверка формата
print (x_test.shape)      # (1620000, 2, 128)
print (y_train.shape)    # (3780000, 9)
print (y_test.shape)      # (1620000, 9)
# добавляем размерность
x_train = x_train.reshape(x_train.shape[0], 2, 128,1)
x_test = x_test.reshape(x_test.shape[0], 2, 128,1)
# Сохраняем X_train и X_test
np.save("D:/DSET9/Signal_test", x_test)
np.save("D:/DSET9/Signal_train", x_train)
# Сохраняем Y_train и Y_test
np.save("D:/DSET9/test_label", y_test)
np.save("D:/DSET9/train_label", y_train)

2. Архитектура нейронной сети

Наша НС является сетью прямого распространения, относится к типу обучения с учителем (supervised learning) и похожа на VGG-16 с добавлением на входе слоя нормализации BatchNormalization для устранения ковариационного сдвига. Далее применены три сдвоенных двумерных сверточных слоя Conv2D со следующим порядком фильтров 32, 64, 128 с размером окна (1,3) и (2,3) для первого слоя и (2,3) для всех остальных. Дальнейшее увеличение размера ядра свертки и количества сверточных слоев не дает существенного улучшения качества распознавания. Функция активации – линейный выпрямитель relu. Затем проводились эксперименты с плотным слоем Dense, было обнаружено, что увеличение количества нейронов сверх 256 не улучшает производительность. Для устранения эффекта переобучения добавлялись параметры регуляризации Dropout до оптимальных значений в 20% для первого и второго и 30% третьего слоя Conv2D. После плотного слоя Dense так же добавлен слой Dropout в 50 %.

Используем стандартный подход для классификации, в качестве функции ошибки используем "categorical_crossentropy" (категориальную кросс-энтропию), оптимизатор Adam с шагом 0.001, метрику "accuracy".

# загружаем необходимые библиотеки 
from tensorflow.keras.models import Sequential #Сеть прямого распространения
#Базовые слои для свёрточных сетей
from tensorflow.keras.layers import Dense, Conv2D, MaxPooling2D, Flatten, Dropout, BatchNormalization
from tensorflow.keras.optimizers import Adam # оптимизатор
from tensorflow.keras.preprocessing import image #Для отрисовки изображений
from sklearn.preprocessing import LabelEncoder, StandardScaler # Функции для нормализации данных
from sklearn import preprocessing # Пакет предварительной обработки данных
import numpy as np #Библиотека работы с массивами
import matplotlib.pyplot as plt #Для отрисовки графиков
import math # математика 
import os #Для работы с файлами 
import pandas as pd

%matplotlib inline
#============= Загружаем DataSet train\test выборки============
# Train data
Xtrain = np.load('D:/DataSet9/Signal_train.npy')
Ytrain = np.load('D:/DataSet9/train_label.npy')
# Test data
Xtest  = np.load('D:/DataSet9/Signal_test.npy')
Ytest  = np.load('D:/DataSet9/test_label.npy')
# =================СЕТЬ============
#задаём batch_size
batch_size = 512
#Создаем последовательную модель
model = Sequential()
model.add(BatchNormalization(input_shape=(2, 128, 1)))
#Первый сверточный слой
model.add(Conv2D(32, (1, 3), padding='same', activation='relu'))
model.add(Conv2D(32, (2, 3), padding='same', activation='relu'))
model.add(MaxPooling2D(pool_size=(1, 2)))
model.add(Dropout(0.2))
#Второй сверточный слой
model.add(Conv2D(64, (2, 3), padding='same', activation='relu'))
model.add(Conv2D(64, (2, 3), padding='same', activation='relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))
#Слой регуляризации Dropout
model.add(Dropout(0.2))
#Третий сверточный слой
model.add(Conv2D(128, (2, 3), padding='same', activation='relu'))
model.add(Conv2D(128, (2, 3), padding='same', activation='relu'))
model.add(MaxPooling2D(pool_size=(1, 2)))
#Слой регуляризации Dropout
model.add(Dropout(0.3))
model.add(Flatten())
#Полносвязный слой для классификации
model.add(Dense(256, activation='relu'))
model.add(Dropout(0.5))
#Выходной полносвязный слой
model.add(Dense(9, activation='softmax'))   #
#Компилируем сеть
model.compile(loss="categorical_crossentropy", optimizer=Adam(lr=1e-3), metrics=["accuracy"])

3. Обучение, тестирование, эксперименты

Методом fit запускаем обучение, на вход сети поступают сегменты Xtrain и Ytrain размером batch_size и последовательно прогоняются через все слои обучения. Наша обучающая выборка состоит из 7383 батчей (batch) по 512 семплов, что соответствует одной эпохе. Одна эпоха — это один раз полностью пройденная моделью обучающая выборка, в нашем случае время обработки 1 эпохи заняло около 23 минут. Всего модель обучалась на 10 эпохах.

#Обучаем сеть 
history = model.fit(Xtrain, 
                    Ytrain, 
                    batch_size=batch_size, 
                    epochs=10,
                    validation_data=(Xtest, Ytest),
                    verbose=1)

Epoch 1/10
7383/7383 [==============================] - 1327s 180ms/step - loss: 0.2671 - accuracy: 0.8936 - val_loss: 0.0899 - val_accuracy: 0.9636
Epoch 2/10
7383/7383 [==============================] - 1339s 181ms/step - loss: 0.1544 - accuracy: 0.9391 - val_loss: 0.0520 - val_accuracy: 0.9779
Epoch 3/10
7383/7383 [==============================] - 1402s 190ms/step - loss: 0.1156 - accuracy: 0.9548 - val_loss: 0.0369 - val_accuracy: 0.9843
Epoch 4/10
7383/7383 [==============================] - 1392s 189ms/step - loss: 0.0947 - accuracy: 0.9640 - val_loss: 0.0273 - val_accuracy: 0.9883
Epoch 5/10
7383/7383 [==============================] - 1355s 184ms/step - loss: 0.0808 - accuracy: 0.9693 - val_loss: 0.0213 - val_accuracy: 0.9905
Epoch 6/10
7383/7383 [==============================] - 1346s 182ms/step - loss: 0.0716 - accuracy: 0.9732 - val_loss: 0.0214 - val_accuracy: 0.9902
Epoch 7/10
7383/7383 [==============================] - 1410s 191ms/step - loss: 0.0672 - accuracy: 0.9749 - val_loss: 0.0154 - val_accuracy: 0.9938
Epoch 8/10
7383/7383 [==============================] - 1389s 188ms/step - loss: 0.0639 - accuracy: 0.9766 - val_loss: 0.0195 - val_accuracy: 0.9908
Epoch 9/10
7383/7383 [==============================] - 1340s 182ms/step - loss: 0.0567 - accuracy: 0.9795 - val_loss: 0.0390 - val_accuracy: 0.9860
Epoch 10/10
7383/7383 [==============================] - 1375s 186ms/step - loss: 0.0538 - accuracy: 0.9811 - val_loss: 0.0116 - val_accuracy: 0.9959
# После обучения получили точность на валидационной (тестовой) выборке в 99.59 % 

Делаем проверку

Создаем проверочный массив PROV и метку Prov_Metka  аналогично обучающему массиву, берем не использованные при обучении данные из файлов до 8192 байта, получаем массивы (252000, 2, 128, 1) и (252000, 9) соответственно. Методом evaluate вычисляем долю верно распознанных семплов нашей обученной модели.

# Загружаем проверку 
PROV = np.load('D:/DataSet9/PROVERKA/Signal_prov.npy')
Prov_Metka = np.load("D:/DataSet9/PROVERKA/Label_prov.npy")
print(PROV.shape)
print(Prov_Metka.shape)
# Вычисляем результаты сети на PROVERKA наборе
scores = model.evaluate(PROV, Prov_Metka, verbose=1)
print(scores)
print("Доля верных ответов на данных PROV, в процентах: ", round(scores[1] * 100, 4), "%", sep="")
(252000, 2, 128, 1)
(252000, 9)
7875/7875 [==============================] - 41s 5ms/step - loss: 0.0519 - accuracy: 0.9838
[0.051874879747629166, 0.9837777614593506]
# Доля верных ответов на данных PROV, в процентах: 98.38%, ошибка:  0.0519 

Сделаем матрицу распределения ошибок классификации. Из массива PROV будем «откусывать» по 100 семплов из каждого файла и прогонять через predict (20 файлов по 100 = 2000 семплов) для каждого вида модуляции.

qpsk

oqpsk

p/4qpsk

qam16

qam32

qam64

qam128

qam256

qam512

qpsk

2000

0

0

0

0

0

0

0

0

oqpsk

0

2000

0

0

0

0

0

0

0

p/4qpsk

0

0

2000

0

0

0

0

0

0

qam16

0

0

0

1954

0

44

0

2

0

qam32

0

0

0

0

1965

10

25

0

0

qam64

0

0

0

7

0

1993

0

0

0

qam128

0

0

0

0

14

0

1903

0

83

qam256

0

0

0

0

7

15

0

1959

19

qam512

0

0

0

0

0

0

107

0

1893

При анализе таблицы видно, что ошибки классификации появляются, если один класс является подмножеством другого. Например, модуляция QAM128 имеет вид созвездия «крест» и входит в подмножество модуляции QAM512 и если оба сигнала имеют одинаковую частоту дискретизации и символьную скорость, то сеть иногда их путает.

Оценка качества классификации с использованием утилиты classification_report от Scikit-learn

precision

recall

f1-score

support

qpsk

1.00

1.00

1.00

2000

oqpsk

1.00

1.00

1.00

2000

p/4qpsk

1.00

1.00

1.00

2000

qam16

1.00

0.98

0.99

2000

qam32

0.99

0.98

0.99

2000

qam64

0.97

1.00

0.98

2000

qam128

0.93

0.95

0.94

2000

qam256

1.00

0.98

0.99

2000

qam512

0.95

0.94

0.95

2000

Прогоним через predict по 200 синтезированных I/Q семплов QAM32 с символьной скоростью отличной от сигналов в обучающем наборе.

mode

qpsk

oqpsk

p/4qpsk

qam16

qam32

qam64

qam128

qam256

qan512

qam32_12

0

0

0

0

200

0

0

0

0

qam32_18

0

0

0

0

187

0

13

0

0

qam32_32

0

0

0

0

200

0

0

0

0

qam32_40

0

0

0

0

1

177

0

22

0

qam32_46

0

0

0

1

0

199

0

0

0

qam32_50

0

0

0

0

200

0

0

0

0

Тестовые сигналы, у которых в обучающем наборе были синтезированные сигналы с близкой символьной скоростью, показали хорошую точность. В обучающей выборке в диапазоне символьных скоростей 40-46 МБод для QAM32 были реальные эфирные данные с канальными шумами и эффектами многолучевого распространения сигнала, а для сигнала QAM64 только синтезированные сигналы. Поэтому можно предположить, что сеть ошибочно распознала QAM64 по совокупности признаков в обучающем наборе.

4. Вывод

Рассмотренная сеть CNN соответствует минимальному требованию классификации, но для получения качественного классификатора требуется очень большой массив (Dataset) обучения в котором должны отражаться все эффекты многолучевого распространения сигнала реальных каналов связи, что сделать весьма затруднительно. Для эффективной классификации необходима архитектура сети, которая выделяет только «семантическую соль» и не сильно требовательна к обучающему массиву. Поэтому в последнее время лидируют различные гибридные сети, например сеть с многоуровневым вниманием MCBL (Multilevel attention CNN Bi-LSTM) [4] от китайской команды 63-rd Research Institute, National University of Defense Technology, Nanjing.

Аргументированная и обоснованная критика приветствуется)))

Используемые источники:

  1. https://github.com/radioML/examples/blob/master/modulation_recognition/RML2016.10a_VTCNN2_example.ipynb

  2. https://arxiv.org/pdf/1602.04105v2.pdf

  3. https://www.deepsig.ai/datasets

  4. https://doi.org/10.1155/2021/1991471