Training a ResNet Model for Channel Estimation

Now that we have a channel-estimation dataset, we can train a neural network to estimate the effective channel from the transmitted DMRS symbols and their corresponding received values. The model takes one receive antenna at a time as input and predicts the effective channel for that receive antenna.

This notebook uses the PyTorch framework, but the same dataset can also be used with other machine-learning tools. The following diagram shows the neural-network structure used in this example.

NN Structure

The architecture is intentionally small and fixed to the tensor dimensions generated in the previous notebook. This keeps the tutorial focused on the complete channel-estimation workflow rather than on model-design details. In a more general implementation, the number of input channels, output channels, OFDM symbols, and subcarriers could be derived from the dataset metadata or from the NeoRadium configuration.

Let’s start by importing the required modules.

[1]:
import numpy as np
import time, datetime

import torch
from torch import nn
from torch.optim.lr_scheduler import ExponentialLR

Loading the Dataset

We first load the dataset files generated in the previous step. Then we create separate PyTorch datasets for training, validation, and testing.

Each sample contains the real and imaginary parts of the transmitted DMRS layers and the received DMRS observations for one receive antenna. Each label contains the real and imaginary parts of the corresponding effective channel.

[2]:
# Define the dataset object
# ----------------------------------------------------------------------------------------------------------------------
class ChEstDataset():
    # ------------------------------------------------------------------------------------------------------------------
    def __init__(self, dataFile, batchSize=64):
        self.batchSize = batchSize
        sampleAndLabels = np.load(dataFile)
        x = sampleAndLabels.shape[1]//2+1
        self.samples, self.labels = sampleAndLabels[:,:x,:,:], sampleAndLabels[:,x:,:,:]
        self.numSamples = self.samples.shape[0]

    # ------------------------------------------------------------------------------------------------------------------
    def batches(self, device=None, shuffle=False):
        numBatches = self.numSamples//self.batchSize
        if numBatches*self.batchSize < self.numSamples: numBatches += 1

        sampleOrder = np.arange(self.numSamples)
        if shuffle: np.random.shuffle(sampleOrder)

        for batch in range(numBatches):
            batchIndexes = sampleOrder[batch*self.batchSize : (batch+1)*self.batchSize]
            batchSamples, batchLabels = ( torch.from_numpy(self.samples[batchIndexes]),
                                          torch.from_numpy(self.labels[batchIndexes]) )
            if device is not None:
                batchSamples, batchLabels = batchSamples.to(device), batchLabels.to(device)
            yield batchSamples, batchLabels

# Instantiate the training, validation, and test datasets
trainDs = ChEstDataset("ChestTrain.npy")
validDs = ChEstDataset("ChestValid.npy")
testDs  = ChEstDataset("ChestTest.npy")

print(f"{trainDs.numSamples} training samples")
print(f"{validDs.numSamples} validation samples")
print(f"{testDs.numSamples} test samples")
14000 training samples
2000 validation samples
4000 test samples

Creating the Model

Next, we create the model that will be used for training. We first define the ResBlock module shown in the diagram above. The block uses convolution, batch normalization, ReLU activations, and a residual connection.

We then define the channel-estimation network (ChEstNet) using two ResBlock instances followed by one additional convolutional layer. The final layer maps the learned features to the real and imaginary parts of the predicted effective channel.

[3]:
# ----------------------------------------------------------------------------------------------------------------------
# Define the residual block
class ResBlock(nn.Module):
    # ------------------------------------------------------------------------------------------------------------------
    def __init__(self, inDepth, midDepth, outDepth, kernel=(3,3), stride=(1,1)):
        super().__init__()
        if isinstance(stride, int): stride = (stride, stride)
        if isinstance(kernel, int): kernel = (kernel, kernel)

        self.path1 = nn.Sequential(
            nn.Conv2d(inDepth, midDepth, 1, stride, padding='valid'),  # 1x1 conv.
            nn.BatchNorm2d(midDepth),
            nn.ReLU(True),
            nn.Conv2d(midDepth, midDepth, kernel, padding='same'),
            nn.BatchNorm2d(midDepth),
            nn.ReLU(True),
            nn.Conv2d(midDepth, outDepth, 1, padding='valid'), # 1x1 conv.
            nn.BatchNorm2d(outDepth))

        self.path2 = None
        if ((stride != (1,1)) or (inDepth!=outDepth)):
            self.path2 = nn.Sequential(nn.Conv2d(inDepth, outDepth, 1, stride),  # 1x1 conv.
                                       nn.BatchNorm2d(outDepth) )

    # ------------------------------------------------------------------------------------------------------------------
    def forward(self, x):
        out = (self.path1(x) + x) if self.path2 is None else (self.path1(x) + self.path2(x))
        out = nn.ReLU(True)(out)
        return out

# ----------------------------------------------------------------------------------------------------------------------
# Define the ChEstNet model
class ChEstNet(nn.Module):
    # ------------------------------------------------------------------------------------------------------------------
    def __init__(self, device):
        super().__init__()
        self.inShape = (6, 14, 288)
        self.res1 = ResBlock(6, 48, 192, (9,9))     # Residual block with a 9×9 kernel
        self.res2 = ResBlock(192, 48, 192, (7,7))   # Residual block with a 7×7 kernel
        self.res3 = ResBlock(192, 48, 192, (3,3))   # Residual block with a 3x3 kernel
        self.conv = nn.Conv2d(192, 4, 3, padding='same')
        self.to(device)

    # ------------------------------------------------------------------------------------------------------------------
    def forward(self, x):
        out = self.res1(x)
        out = self.res2(out)
        out = self.res3(out)
        out = self.conv(out)
        return out

    # ------------------------------------------------------------------------------------------------------------------
    @property
    def numParams(self):    return sum( p.numel() for p in self.parameters())
    @property
    def device(self):       return next(self.parameters()).device

    # ------------------------------------------------------------------------------------------------------------------
    def trainEpoch(self, trainDS, lossFunction, optimizer):
        self.train()    # Set the model to training mode
        lossMin, lossSum, lossMax = torch.inf, 0, -torch.inf

        n = 0
        for batchSamples, batchLabels in trainDS.batches(self.device, shuffle=True):
            # Compute prediction and loss
            batchPredictions = self( batchSamples )
            loss = lossFunction(batchPredictions, batchLabels)

            optimizer.zero_grad()
            loss.backward()  # Backpropagation
            optimizer.step()

            lossValue = loss.item()

            batchSize = batchSamples.shape[0]
            lossSum += lossValue * batchSize
            if lossValue>lossMax: lossMax = lossValue
            if lossValue<lossMin: lossMin = lossValue
            n += batchSamples.shape[0]

        return lossMin, lossSum/n, lossMax

    # ------------------------------------------------------------------------------------------------------------------
    # Evaluation loop:
    def evaluate(self, evalDS, lossFunction):
        self.eval()  # Set the model to evaluation mode

        lossSum, n = 0, 0
        with torch.no_grad():
            for batchSamples, batchLabels in evalDS.batches(self.device):
                batchSize = batchSamples.shape[0]
                batchPredictions = self( batchSamples ).to(batchLabels.dtype)
                # Accumulate the loss for the whole batch
                batchLoss = lossFunction(batchPredictions, batchLabels).item() * batchSize
                lossSum += batchLoss
                n += batchSize

        return lossSum/n

    # ------------------------------------------------------------------------------------------------------------------
    def infer(self, samples, toNumpy=True):
        self.eval()  # Set the model to evaluation mode
        if type(samples) is np.ndarray: samples = torch.from_numpy(samples)
        with torch.no_grad():
            if toNumpy: return self(samples.to(self.device)).cpu().numpy()
            return self(samples.to(self.device))

    # ------------------------------------------------------------------------------------------------------------------
    def saveParams(self, fileName):
        torch.save(self.state_dict(), fileName)

    # ------------------------------------------------------------------------------------------------------------------
    def loadParams(self, fileName):
        if type(fileName)==str:
            self.load_state_dict( torch.load(fileName, weights_only=True, map_location=self.device) )
        else:
            self.load_state_dict(fileName)

Training the Model

The following cell defines the training and validation loops and uses them to train the model. The validation loss is monitored during training, and the model parameters with the lowest validation loss are saved for later evaluation.

Note: The following cell can take several hours to complete, depending on the hardware. A file containing trained model parameters is included in this directory, so you can skip the training cell and proceed to the evaluation step if you only want to run the demonstration.

The training run is not configured for strict bit-exact reproducibility. Different runs may produce slightly different weights, but the overall model performance should remain similar when trained with the same dataset and architecture.

[4]:
numEpochs = 100                    # Number of epochs
learningRate = (0.002, 0.00002)    # Learning rate starts at 0.002 and exponentially decays to 0.00002

# Check GPU availability
device = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu"
print(f"Using {device} device")
model = ChEstNet(device)

lossFunction = nn.MSELoss()         # Use MSE as the loss function
if isinstance(learningRate,tuple):  # If learningRate is a tuple, use an exponentially decaying learning rate
    from torch.optim.lr_scheduler import ExponentialLR
    lr1st, lrLast = learningRate
    optimizer = torch.optim.Adam(model.parameters(), lr=lr1st)
    lrScheduler = ExponentialLR(optimizer, np.exp(np.log(lrLast/lr1st)/(numEpochs-1)))
else:                               # If learningRate is a number, use a fixed learning rate
    optimizer = torch.optim.Adam(model.parameters(), lr=learningRate)
    lrScheduler = None              # No learning-rate scheduling is needed

t0 = time.monotonic()
print("Epoch   Learning Rate   Training Loss   Validation Loss")
print("-----   -------------   -------------   ---------------")
lowestLoss = None
for epoch in range(numEpochs):
    print(" %-4d     %-10f      "%(epoch+1, lrScheduler.get_last_lr()[0]), end="")
    lossMin, lossMean, lossMax = model.trainEpoch(trainDs, lossFunction, optimizer)
    print("%-10f      "%(lossMean), end="")
    validLoss = model.evaluate(validDs, lossFunction)
    if lowestLoss is None:
        lowestLoss = validLoss
        torch.save(model.state_dict(), 'Models/ChEstModelWeights.pth')
        print("%-10f   "%(validLoss))
    elif validLoss<lowestLoss:          # This is the best model so far, so save it
        lowestLoss, bestEpoch = validLoss, epoch+1
        torch.save(model.state_dict(), 'Models/ChEstModelWeights.pth')
        print("%-10f * "%(validLoss))   # The '*' indicates the best model so far and that it was saved
    else:
        print("%-10f   "%(validLoss))

    if lrScheduler is not None: lrScheduler.step()

print(f"Training complete. (Training time: {str(datetime.timedelta(seconds=int(time.monotonic()-t0)))})")
Using cuda device
Epoch   Learning Rate   Training Loss   Validation Loss
-----   -------------   -------------   ---------------
 1        0.002000        0.938700        0.299167
 2        0.001909        0.369134        0.404431
 3        0.001822        0.319097        0.312149
 4        0.001739        0.302045        0.412153
 5        0.001660        0.271935        0.202254   *
 6        0.001585        0.256703        0.251782
 7        0.001513        0.249186        0.239740
 8        0.001444        0.225596        0.250165
 9        0.001379        0.218456        0.199255   *
 10       0.001316        0.220275        0.196553   *
 11       0.001256        0.216713        0.215160
 12       0.001199        0.199733        0.175139   *
 13       0.001144        0.198662        0.152383   *
 14       0.001092        0.191724        0.162259
 15       0.001043        0.183435        0.153435
 16       0.000995        0.175529        0.159840
 17       0.000950        0.173251        0.141395   *
 18       0.000907        0.169715        0.158897
 19       0.000866        0.164087        0.151752
 20       0.000826        0.162592        0.157471
 21       0.000789        0.159753        0.147347
 22       0.000753        0.155921        0.133097   *
 23       0.000719        0.154368        0.144307
 24       0.000686        0.152605        0.134776
 25       0.000655        0.149900        0.137753
 26       0.000625        0.146861        0.149590
 27       0.000597        0.143483        0.131100   *
 28       0.000570        0.146480        0.130053   *
 29       0.000544        0.140951        0.137649
 30       0.000519        0.140800        0.130080
 31       0.000495        0.136195        0.126247   *
 32       0.000473        0.136997        0.130804
 33       0.000451        0.136491        0.123413   *
 34       0.000431        0.134396        0.119934   *
 35       0.000411        0.134705        0.121562
 36       0.000393        0.131477        0.121920
 37       0.000375        0.131144        0.126812
 38       0.000358        0.129563        0.129650
 39       0.000341        0.128330        0.121826
 40       0.000326        0.128159        0.123329
 41       0.000311        0.126385        0.118147   *
 42       0.000297        0.126128        0.118586
 43       0.000283        0.125762        0.120828
 44       0.000271        0.125648        0.118195
 45       0.000258        0.124230        0.117378   *
 46       0.000247        0.122944        0.116941   *
 47       0.000235        0.124195        0.117355
 48       0.000225        0.122100        0.117292
 49       0.000214        0.121046        0.114693   *
 50       0.000205        0.121399        0.117613
 51       0.000195        0.120147        0.117187
 52       0.000187        0.119608        0.114770
 53       0.000178        0.118788        0.115535
 54       0.000170        0.120268        0.115052
 55       0.000162        0.118124        0.114397   *
 56       0.000155        0.117903        0.114606
 57       0.000148        0.117129        0.114472
 58       0.000141        0.117004        0.113578   *
 59       0.000135        0.115900        0.116340
 60       0.000129        0.116098        0.113596
 61       0.000123        0.116178        0.114125
 62       0.000117        0.116031        0.113689
 63       0.000112        0.116244        0.114568
 64       0.000107        0.115590        0.114396
 65       0.000102        0.114377        0.112989   *
 66       0.000097        0.114781        0.112893   *
 67       0.000093        0.114189        0.113360
 68       0.000089        0.113873        0.112096   *
 69       0.000085        0.113713        0.112275
 70       0.000081        0.114048        0.112231
 71       0.000077        0.113120        0.113692
 72       0.000074        0.113684        0.112383
 73       0.000070        0.113008        0.112376
 74       0.000067        0.113242        0.112588
 75       0.000064        0.112798        0.111806   *
 76       0.000061        0.112153        0.111362   *
 77       0.000058        0.112353        0.112119
 78       0.000056        0.111996        0.111579
 79       0.000053        0.112043        0.111400
 80       0.000051        0.112224        0.111796
 81       0.000048        0.111636        0.110985   *
 82       0.000046        0.111911        0.111708
 83       0.000044        0.111451        0.111613
 84       0.000042        0.111480        0.111347
 85       0.000040        0.111761        0.111579
 86       0.000038        0.111149        0.111516
 87       0.000037        0.111740        0.111231
 88       0.000035        0.110919        0.110838   *
 89       0.000033        0.110736        0.110849
 90       0.000032        0.110981        0.111226
 91       0.000030        0.110846        0.111002
 92       0.000029        0.111162        0.110930
 93       0.000028        0.110887        0.110852
 94       0.000026        0.110676        0.111018
 95       0.000025        0.111028        0.110817   *
 96       0.000024        0.110587        0.110395   *
 97       0.000023        0.110451        0.110731
 98       0.000022        0.109942        0.110553
 99       0.000021        0.110439        0.110860
 100      0.000020        0.110155        0.110743
Training complete. (Training time: 0:39:13)

Evaluating the Model on the Test Dataset

After training, we evaluate the saved model on the held-out test dataset. This provides a dataset-level error metric that is independent of the later link-level BER simulation.

A convenient metric for channel estimation is mean squared error (MSE), because it measures the channel-estimation error relative to the power of the true channel. The BER evaluation in the next notebook is still the main end-to-end performance result, but this test-set metric is useful for checking that the model generalizes beyond the training and validation datasets.

[5]:
model.loadParams('Models/ChEstModelWeights.pth') # Load the best model
testLoss = model.evaluate(testDs, lossFunction)
print(f"Test MSE loss: %.6f"%(testLoss))
Test MSE loss: 0.112375
[ ]: