Wow, thanks for the quick response!
The reason that they were in that order was that I got a nag message from OneCycleLR for placing the optimizer step function ahead of the learning rate scheduler step function. To reverse their order, I copy-pasted the optimizer up to its current position in the script.
After updating the training loop so everything goes at the end:
        for epoch in range(self.n_epochs):
            lvs = []
            for i, (X_batch, y_batch) in enumerate(batches):
                X_batch = X_batch.cuda()
                y_batch = y_batch.cuda()
                
                y_pred = model(X_batch).squeeze()
                loss = self.loss_fn(y_pred, y_batch)
                optimizer.zero_grad()
                
                loss.backward()
                
                lv = loss.detach().cpu().numpy()
                lvs.append(lv)
                if i % 100 == 0:
                    print(f"Epoch {epoch + 1}/{self.n_epochs}; Batch {i}; Loss {lv}")

                optimizer.step()
                scheduler.step()
            print(
                f"Epoch {epoch + 1}/{self.n_epochs}; Average Loss {np.mean(lvs)}"
            )

It works. Thanks for the help! Will definitely be more careful about the ordering of these steps going forward.