Analyze my code and look for the shortfalls in performance. An y edits you suggest must adhere to correct code fundamentals and good reasoning you must explain all changes and explain the expected change

path = '/kaggle/input/m4-forecasting-competition-dataset/Hourly-train.csv'
data = pd.read_csv(path, parse_dates=['V1'], index_col='V1')

# Fill missing values using interpolation or other methods as appropriate
data = data.interpolate(method='linear').fillna(method='bfill').fillna(method='ffill')

# Normalize the data
scaler = StandardScaler()
scaler.set_output(transform='pandas')


def split_data(data, col_indices, train_size=0.7, val_size=0.15, scaler=None):
    # Extract the specified rows from the DataFrame
    extracted_data = data.iloc[:, col_indices]

    if scaler:
        extracted_data = pd.DataFrame(scaler.fit_transform(extracted_data), 
                            columns=extracted_data.columns,
                            index=extracted_data.index)
    
    test_size = 1 - train_size - val_size
    
    train_data, remaining_data = train_test_split(extracted_data, train_size=train_size, shuffle=False)
    
    val_data, test_data = train_test_split(remaining_data, test_size=test_size/(val_size + test_size), shuffle=False)
    
    return train_data, val_data, test_data


in_sequence_length = 32
out_sequence_length = 1
batch_size_ = 128 
train_loader, val_loader, test_loader = create_data_loaders(train_data, val_data, test_data, input_seq_length = in_sequence_length, output_seq_length = out_sequence_length, batch_size = batch_size_)



Model:
# TCN Encoder
class TCNEncoder(nn.Module):
    def __init__(self, input_size, hidden_size, num_layers, kernel_size, dropout):
        super(TCNEncoder, self).__init__()
        layers = []
        num_levels = num_layers

        in_channels = input_size
        out_channels = hidden_size

        for i in range(num_levels):
            dilation_size = 2 ** i
            padding = (kernel_size - 1) * dilation_size
            conv = nn.Conv1d(in_channels, out_channels, kernel_size, padding=padding, dilation=dilation_size)
            conv = nn.utils.parametrizations.weight_norm(conv)
            layers += [conv, nn.ReLU(), nn.Dropout(dropout)]
            in_channels = hidden_size  # For next layer

        self.network = nn.Sequential(*layers)
        self.kernel_size = kernel_size
        self.num_levels = num_levels

    def forward(self, x):
        # x shape: (batch_size, seq_length, input_size)
        x = x.permute(0, 2, 1)  # Reshape input for Conv1d: (batch_size, input_size, seq_length)
        x = self.network(x)
        total_padding = (self.kernel_size - 1) * (2 ** self.num_levels - 1)
        x = x[:, :, :-total_padding] if total_padding != 0 else x  # Remove extra padding
        x = x.permute(0, 2, 1)  # Back to (batch_size, seq_length, hidden_size)
        return x

    
# Transformer Decoder with masked multi-head attention
class TransformerDecoder(nn.Module):
    def __init__(self, input_size, hidden_size, num_layers, num_heads, dropout):
        super(TransformerDecoder, self).__init__()
        self.embedding = nn.Linear(input_size, hidden_size)
        self.positional_encoding = PositionalEncoding(hidden_size, dropout)
        decoder_layer = nn.TransformerDecoderLayer(d_model=hidden_size, nhead=num_heads, dropout=dropout)
        self.transformer_decoder = nn.TransformerDecoder(decoder_layer, num_layers=num_layers)
        self.fc_out = nn.Linear(hidden_size, input_size)

    def forward(self, tgt, memory, tgt_mask):
        # tgt: (batch_size, tgt_seq_length, input_size)
        tgt_emb = self.embedding(tgt)
        tgt_emb = self.positional_encoding(tgt_emb)
        memory = memory.transpose(0, 1)  # (src_seq_length, batch_size, hidden_size)
        tgt_emb = tgt_emb.transpose(0, 1)  # (tgt_seq_length, batch_size, hidden_size)
        output = self.transformer_decoder(tgt_emb, memory, tgt_mask=tgt_mask)
        output = output.transpose(0, 1)  # Back to (batch_size, tgt_seq_length, hidden_size)
        output = self.fc_out(output)
        return output

    
# TCN-Encoder-Transformer-Decoder Model
class TCNEncoderTransformerDecoder(nn.Module):
    def __init__(self, input_size, hidden_size, num_layers, kernel_size, num_heads, dropout):
        super(TCNEncoderTransformerDecoder, self).__init__()
        self.encoder = TCNEncoder(input_size, hidden_size, num_layers, kernel_size, dropout)
        self.decoder = TransformerDecoder(input_size, hidden_size, num_layers, num_heads, dropout)
        self.positional_encoding = PositionalEncoding(hidden_size, dropout)  # For encoder outputs

    def forward(self, src, tgt):
        # src: (batch_size, src_seq_length, input_size)
        # tgt: (batch_size, tgt_seq_length, input_size)
        # Encoder
        memory = self.encoder(src)  # (batch_size, src_seq_length, hidden_size)
        memory = self.positional_encoding(memory)  # Add positional encoding to memory
        # Decoder
        tgt_mask = nn.Transformer.generate_square_subsequent_mask(tgt.size(1)).to(tgt.device)  # (tgt_seq_length, tgt_seq_length)
        output = self.decoder(tgt, memory, tgt_mask)
        return output


# Positional Encoding class (unchanged)
class PositionalEncoding(nn.Module):
    def __init__(self, d_model, dropout=0.1, max_len=5000):
        super(PositionalEncoding, self).__init__()
        self.dropout = nn.Dropout(p=dropout)
        
        pe = torch.zeros(max_len, d_model)  # Create positional encoding matrix
        position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
        div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-np.log(10000.0) / d_model))
        pe[:, 0::2] = torch.sin(position * div_term)  # Apply sine to even indices
        pe[:, 1::2] = torch.cos(position * div_term)  # Apply cosine to odd indices
        pe = pe.unsqueeze(0)
        self.register_buffer('pe', pe)  # Save positional encoding matrix

    def forward(self, x):
        x = x + self.pe[:, :x.size(1), :].to(x.device)  # Add positional encoding
        return self.dropout(x)



#Training Support fucntion

#_prepare_decoder_input for Teacher Forcing
def _prepare_decoder_input(batch_x: torch.Tensor,batch_y: torch.Tensor):
    """Prepare decoder input using teacher forcing"""
    decoder_input = torch.zeros_like(batch_y)
    decoder_input[:, 1:, :] = batch_y[:, :-1, :]
    decoder_input[:, 0, :] = batch_x[:, -1, :]
    return decoder_input


#check and fromat model inputs
def _prepare_batch(batch_x: torch.Tensor,batch_y: torch.Tensor,device: torch.device):
    """Prepare input batch for processing"""
    if batch_y.dim() == 1:
        batch_x = batch_x.to(device).unsqueeze(-1)
        batch_y = batch_y.to(device).unsqueeze(-1)
    elif batch_y.dim() > 1:
        batch_x = batch_x.to(device)
        batch_y = batch_y.to(device)
    else:
        raise ValueError("The input doesn't fit the expected shape of a uni or multi variate Timeseries")
    
    return batch_x, batch_y

# Plot losses:

Train function:

# Function to train the model with teacher forcing
def train_model(model, train_loader, val_loader, num_epochs, learning_rate, gradient_clip_val):
    criterion = nn.MSELoss()
    optimizer = optim.Adam(model.parameters(), lr=learning_rate)
    processor_type= 'cuda' if torch.cuda.is_available() else 'cpu'
    scaler = GradScaler()
    device = torch.device(processor_type)
    model.to(device)
    
    scheduler = torch.optim.lr_scheduler.OneCycleLR(optimizer, max_lr=learning_rate*2, steps_per_epoch=len(train_loader), epochs=num_epochs)
    
    # Track best validation loss for early stopping
    best_val_loss = float('inf')
    patience = 350
    patience_counter = 0
    pbar = tqdm(range(num_epochs), desc=f"Epochs", unit="epoch")
    for epoch in pbar:
        model.train()
        train_loss = 0.0
        train_sMAPE = 0.0
        train_MASE = 0.0
        for batch_x, batch_y in train_loader:
            
            batch_x, batch_y = _prepare_batch(batch_x, batch_y, device)
            # Prepare decoder input by shifting targets (teacher forcing)
            decoder_input = _prepare_decoder_input(batch_x, batch_y)

            optimizer.zero_grad(set_to_none=True)
            
            with autocast(device_type= processor_type):
                output = model(batch_x, decoder_input)
                loss = criterion(batch_y, output)
                batch_sMAPE = smape(batch_y, output)
                batch_MASE = mase(output, batch_y, torch.tensor(train_data.values, dtype=torch.float32).to(device))
            # Scale the loss and call backward
            
            scaler.scale(batch_MASE if (epoch%3 ==0) else loss).backward()
            scaler.unscale_(optimizer)
            torch.nn.utils.clip_grad_norm_(model.parameters(), gradient_clip_val)
            scaler.step(optimizer)
            scaler.update()
            train_loss += loss.item()
            train_sMAPE += batch_sMAPE
            train_MASE += batch_MASE
            scheduler.step()
            
        train_loss /= len(train_loader)
        train_sMAPE /= len(train_loader)
        train_MASE /= len(train_loader)
        
        
        model.eval()
        val_loss = 0.0
        val_sMAPE = 0.0
        val_MASE = 0.0
        with torch.no_grad():
            for batch_x, batch_y in val_loader:
                if batch_y.dim() == 1:
                    batch_x = batch_x.to(device).unsqueeze(-1)
                    batch_y = batch_y.to(device).unsqueeze(-1)
                elif batch_y.dim() > 1:
                    batch_x = batch_x.to(device)
                    batch_y = batch_y.to(device)
                else:
                    print("The input doesn't fit the expected shape of a uni or multi variate Timeseries")

                decoder_input = torch.zeros_like(batch_y)
                decoder_input[:, 1:, :] = batch_y[:, :-1, :]
                decoder_input[:, 0, :] = batch_x[:, -1, :]
                with autocast(device_type= processor_type):
                    output = model(batch_x, decoder_input)
                    loss = criterion(output, batch_y)
                    batch_sMAPE = smape(batch_y, output)
                    batch_MASE = mase(batch_y, output, torch.tensor(train_data.values, dtype=torch.float32).to(device))
                val_loss += loss.item()
                val_sMAPE += batch_sMAPE
                val_MASE += batch_MASE
                
            val_loss /= len(val_loader)
            val_sMAPE /= len(val_loader)
            val_MASE /= len(val_loader)
                # Early stopping check
        if val_loss < best_val_loss:
            best_val_loss = val_loss
            patience_counter = 0
        elif val_loss > (best_val_loss*1.05):
            patience_counter += 1
            if patience_counter >= patience:
                print(f"\nEarly stopping triggered after {epoch + 1} epochs")
                break
            

        # Log to wandb
        wandb.log({"Epoch": epoch + 1, "Train Loss": train_loss, "Train sMAPE": train_sMAPE, "Train MASE": train_MASE, 
                   "Validation Loss": val_loss, "Validation sMAPE": val_sMAPE, "Validation MASE": val_MASE,
                   "Learning Rate": scheduler.get_last_lr()[0],})
        # uUdate progress bar
        pbar.set_description(f"Epoch {epoch + 1}/{num_epochs} - Average Loss: {val_loss:.4f} - LR: {scheduler.get_last_lr()[0]:.6f}")
   
    # Finalize wandb logging
    wandb.finish()
    return model