GAN classes have to be condensed!
From an objective programming point of view, the Generator and the Discriminator can be introduced as properties of a GAN class object as self._generator and self._discriminator. By doing so, the training logic, is further integrated into a method called train. What the original GAN paper describes, is an iterative training algorithm, which first trains the Discriminator model, which is implemented as a neural network. And then the Generator model consequently. After that, the next epoch starts the process again. How the two models implement backpropagation, e.g. through ADAM or SGD algorithm, is independent from each other.
              
              class SimpleGan():
                def __init__(self):
                    self.n_epochs = 20000
                    self.batch_size = 512

                    # Submodels
                    self._generator = Generator(self.batch_size)
                    self._discriminator = Discriminator(self.batch_size)
                    
                def train(self):
                  for self._i in range(self.n_epochs):
                      ### Train discriminator ###

                      ### Train generator ###

              
            
During the training of the Discriminator through the loss V(D, G), to optimize the network parameters, the Generator's parameters are not changed. In other words are not influenced through the backwards propagation. In the context of neural networks, this is often times called freezing and in the context of PyTorch the tensor elements gradient are shut off. Those are accessible through the parameters() method. Correspondingly, during the Generator training, the Discriminator is seen as predicting perfectly. Actually it is trained beforehand and at least in the beginning the differentiation between almost randomness and reality might be trainable. The discriminator is basically used to estimate the reality, as the true distribution is not available. In fact, throught the interative training it will be approximated. As the loss is calcualted through the Discriminator and the loss is then used to backpropagate and optimize, the Discriminator has to be frozen at that point, to adapt the Generator network to improve.
              
              class SimpleGan():
                def __init__(self):
                    self.n_epochs = 20000
                    self.batch_size = 512

                    # Submodels
                    self._generator = Generator(self.batch_size)
                    self._discriminator = Discriminator(self.batch_size)
                    
                
                def _freeze_generator(self):
                    # Freeze generator, unfreeze discriminator
                    for p in self._generator.parameters():
                      p.requires_grad = False
                    for p in self._discriminator.parameters():
                      p.requires_grad = True
                
                def _freeze_discriminator(self):
                    # Unfreeze generator, Freeze discriminator
                    for p in self._generator.parameters():
                      p.requires_grad = True
                    for p in self._discriminator.parameters():
                      p.requires_grad = False
                
                def train(self):
                  for self._i in range(self.n_epochs):
                      ### Train discriminator ###
                      self._freeze_generator()

                      ### Train generator ###
                      self._freeze_discriminator()

              
            
To monitor the training process, the losses of both networks after each epoch should be returned and saved in an array. Later on, this gives the possibility to visualize. To access to scalar values from the PyTorch tensor object, here referenced through d_loss and g_loss, the item() method has to be called. The same accounts for the accuracy estimation during training through the self.accs property. A batch of samples drawn from the uniform distribution U(-1, 1) is handed to the generator, which by definition responds with a batch of synthetic samples. Finally, to train the discriminator, those samples, together with the real ones, are used to train it. The training logic of the discriminator model is abstracted to the D class. To train it with batch_size number of samples, for batch_size half number of elements are selected as synthetic and reality each. With regards to the Generator training, batch_size number of samples are created throught the generator first and then forwarded to the Discriminator to predict its reality likeness. Those predicted values are then used to train the Generator through self._generator.train(synt_preds). As for the Discriminator, the training method is abstracted into the Generator class. The accuracy after every epoch is estimated through a prediction from the Discriminator on real and synthetic samples.
              
              class SimpleGan():
                def __init__(self):
                    self.n_epochs = 20000
                    self.batch_size = 512

                    # Losses of D and G network during training
                    self.d_losses = []
                    self.g_losses = []
                    self.accs = []

                    # Submodels
                    self._generator = Generator(self.batch_size)
                    self._discriminator = Discriminator(self.batch_size)
                    
                
                def _freeze_generator(self):
                    # Freeze generator, unfreeze discriminator
                    for p in self._generator.parameters():
                      p.requires_grad = False
                    for p in self._discriminator.parameters():
                      p.requires_grad = True
                
                def _freeze_discriminator(self):
                    # Unfreeze generator, Freeze discriminator
                    for p in self._generator.parameters():
                      p.requires_grad = True
                    for p in self._discriminator.parameters():
                      p.requires_grad = False
                
                def train(self):
                  for self._i in range(self.n_epochs):
                      ### Train discriminator ###
                      self._freeze_generator()
                      # Uniformly distributed U(-1, 1) random noise
                      uni_samples = torch.rand(self.batch_size//2, 1)*2-1
                      # Synthetic data from generator
                      synt_samples = self._generator(uni_samples)
                      real_samples = torch.randn(self.batch_size//2, 1)
                      d_loss = self._discriminator.train(synt_samples,
                                                         real_samples)
                      self.d_losses.append(d_loss.item())

                      ### Train generator ###
                      self._freeze_discriminator()
                      # Uniformly distributed U(-1, 1) random noise
                      uni_samples = torch.rand(self.batch_size, 1)*2-1
                      # Synthetic data from generator
                      synt_samples = self._generator(uni_samples)
                      synt_preds = self._discriminator(synt_samples)
                      g_loss = self._generator.train(synt_preds)
                      self.g_losses.append(g_loss.item())

                      # Estimate accuracy
                      real_preds = self._discriminator(real_samples)
                      real_preds_gt = torch.ones(self.batch_size//2, 1)
                      synt_preds_gt = torch.zeros(self.batch_size, 1)
                      accs_dis = real_preds - real_preds_gt
                      accs_dis_mean = torch.mean(torch.abs(accs_dis))
                      accs_gen = synt_preds - synt_preds_gt
                      accs_gen_mean = torch.mean(torch.abs(accs_gen))
                      acc = (accs_dis_mean + accs_gen_mean)/2
                      self.accs.append(1-acc.item())


              
            
The Generator is trained with a single type of data - synthetic samples. Therefore, the only input parameter for the train() method is synt_preds - the Discriminator predictions on the synthetical samples. Before each optimization step via the actualization of the gradients for each model parameter, those gradients have to be nulled. In PyTorch this is handeled through the zero_grad() method. To be able to use standard BCE as criteria/loss function, the labels for the Generator training have to be inverted. In ohter words, instead of using 0 labels for the fake/synthetic samples, 1 labels are applied. After calculating the loss, the backward step has to be calculated, and finally applied on the tensor parameters with a step on the optimizer algorithm used.
              
              class Generator(nn.Module):
                """ Maps from Uniform distribution 
                    to Gaussian distribution.
                    Latent space of 1D vector input 
                    to 1D vector output.
                """
                def __init__(self, batch_size: int):
                    self.batch_size = batch_size
                    super(Generator, self).__init__()
                    self.lr = 0.0005
                    self.input_size = 1
                    self.output_size = 1

                    self.fc = nn.Linear(self.input_size, 8)
                    self.fc2 = nn.Linear(8, 12)
                    self.fc4 = nn.Linear(12, self.output_size)
                    self.lrelu = nn.LeakyReLU(negative_slope=0.1)
                    
                    self.criterion = torch.nn.BCELoss()
                    self.optimizer = torch.optim.Adam(self.parameters(),
                                                      lr = self.lr)
                
                def forward(self, x: Tensor) -> Tensor:
                    output = self.fc(x)
                    output = self.lrelu(output)
                    output = self.fc2(output)
                    output = self.lrelu(output)
                    output = self.fc4(output)
                    output = self.lrelu(output)

                    return output
                
                def train(self, synt_preds: Tensor) -> Tensor:
                    self.zero_grad()

                    # Use ones, as instead of maximize with 0s, 
                    # minimize with 1s
                    synt_labels = torch.ones(self.batch_size, 1)
                    loss = self.criterion(synt_preds, synt_labels)

                    # Optimize generator model
                    loss.backward()
                    self.optimizer.step()
                    
                    return loss

              
            
Finally for the Discriminator train() method, two input parameters are handed over: synth_samples and real_samples. Those parameters are tensors with batch_size/2 number of samples. Correspondingly, labels are listed with 1s for real input data and 0s for fake/synthetic input data. To determine the current quality of the prediction through the Discriminator, those samples have first to be forwarded through the network, before the calculation the loss through the criteria. As BCE is used as criteria, the loss is calculated with the ground truth. After applying backwards propagation through the Discriminator network, the calculated gradients are used to update the parameters with the optimizer.
              
            class Discriminator(nn.Module):
              """ Decides if a sample is drawn from
                  Gaussian distribution.
              """
              def __init__(self, batch_size: int):
                  self.batch_size = batch_size
                  super(Discriminator, self).__init__()
                  self.lr = 0.0005
                  self.input_size = 1
                  self.output_size = 1
                  self.k = 1

                  self.fc = nn.Linear(self.input_size, 8)
                  self.fc2 = nn.Linear(8, self.output_size)
                  self.lrelu = nn.LeakyReLU(negative_slope=0.1)
                  self.sigmoid = nn.Sigmoid()
                  
                  self.optimizer = torch.optim.Adam(self.parameters(),
                                                    lr = self.lr)
                  self.criterion = torch.nn.BCELoss()

              def forward(self, x: Tensor):
                  output = self.fc(x)
                  output = self.lrelu(output)
                  output = self.fc2(output)
                  output = self.sigmoid(output)

                  return output

              def train(self, synth_samples: Tensor,
                        real_samples: Tensor):
                  # Real data for discriminator training
                  for j in range(self.k):
                      self.zero_grad()
                      
                      # Labels for real data
                      real_gt = torch.ones(self.batch_size//2, 1)
                      # Synthetic labels for generator data
                      synt_gt = torch.zeros(self.batch_size//2, 1)

                      # Predictions from discriminator
                      real_preds = self.forward(real_samples)
                      synt_preds = self.forward(synth_samples)

                      # Loss between ground truth and predictions
                      real_loss = self.criterion(real_preds,
                                                 real_gt)
                      synt_loss = self.criterion(synt_preds,
                                                 synt_gt)
                      loss = real_loss + synt_loss

                      # Optimize discriminator model
                      loss.backward()
                      self.optimizer.step()
                  
                  return loss
              
            

[1] Generative Adversarial Networks - The original paper