Okay, for the record, here is my final code:
import os
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader, Dataset
import matplotlib.pyplot as plt
import matplotlib.ticker as ticker


all_labels = []
all_names = []
all_categories = []
all_letters = []
labels_to_index = {}
letter_to_index = {}


def read_files(file_path):
    for filename in os.listdir(file_path):
        label = os.path.splitext(os.path.basename(filename))[0]
        all_categories.append(label)
        with open(os.path.join(file_path, filename), "r", encoding='utf-8') as f:
            for name in f.read().strip().split('\n'):
                all_names.append(name)
                all_labels.append(label)
                for letter in name:
                    if letter not in all_letters:
                        all_letters.append(letter)


def encode_name(name):
    oh_name = torch.zeros(len(name), len(all_letters))
    for i, char in enumerate(name):
        oh_char = torch.zeros(len(all_letters))
        oh_char[letter_to_index[char]] = 1
        oh_name[i] = oh_char
    return oh_name


def encode_label(label_index):
    oh_label = torch.zeros(1, len(all_categories))
    oh_label[0][label_index] = 1
    return oh_label


def predict(name):
    output, h = rnn(encode_name(name).unsqueeze(1), torch.zeros(1, 1, 128))
    output = output[output.size()[0] - 1]
    return torch.argmax(output, 1)


"""Assign proper values to global variables"""


read_files(r"data/names")

all_letters.sort()

for i, label in enumerate(all_categories):
    labels_to_index[label] = i
all_labels = [labels_to_index[l] for l in all_labels]

for i, letter in enumerate(all_letters):
    letter_to_index[letter] = i


"""Define Dataset"""


class NameDataset(Dataset):

    def __init__(self):
        self.names = []
        self.labels = []

        for name in all_names:
            self.names.append(encode_name(name))

        for label in all_labels:
            self.labels.append(encode_label(label))

    def __len__(self):
        return len(self.names)

    def __getitem__(self, index):
        name = self.names[index]
        label = all_labels[index]
        return name, label


"""Define Model"""


class RNN(nn.Module):

    def __init__(self, input_size, hidden_size, output_size):
        super(RNN, self).__init__()
        self.rnn = nn.RNN(input_size, hidden_size)
        self.linear = nn.Linear(hidden_size, output_size)

    def forward(self, x, h):
        y, h = self.rnn(x, h)
        y = self.linear(y)
        y = F.log_softmax(y, dim=2)
        return y, h


dataset = NameDataset()
train_loader = DataLoader(dataset, shuffle=True, batch_size=None)
h0 = torch.zeros(1, 1, 128)


"""Train"""


if os.path.exists("RNN.pt"):
    rnn = torch.load("RNN.pt")
else:
    rnn = RNN(87, 128, len(all_categories))
    criterion = nn.NLLLoss()
    optimizer = torch.optim.Adam(rnn.parameters(), lr=0.0003)
    epochs = 30
    all_losses = []

    print("Training...")
    for epoch in range(1, epochs+1):
        current_loss = 0
        correct = []
        for x, y in train_loader:
            h = h0
            optimizer.zero_grad()
            x = x.unsqueeze(1)

            y_hat, h = rnn(x, h)
            y_hat = y_hat[y_hat.size()[0]-1]
            y = torch.tensor([y])

            loss = criterion(y_hat, y)
            loss.backward()
            optimizer.step()

            current_loss += loss

            if torch.argmax(y_hat, 1) == y:
                correct.append(1)

        current_accuracy = len(correct) / len(train_loader)
        current_avg_loss = current_loss / len(train_loader)
        all_losses.append(current_avg_loss.item())
        print('%d %d%% loss: %.4f acc: %.4f' % (epoch, epoch / epochs * 100, current_avg_loss, current_accuracy))

    torch.save(rnn, 'RNN.pt')
    plt.figure()
    plt.plot(all_losses)


"""Evaluate Model"""


# Keep track of correct guesses in a confusion matrix
n_categories = len(all_categories)
confusion = torch.zeros(n_categories, n_categories)
n_confusion = 10000


# Go through a bunch of examples and record which are correctly guessed
for i in range(n_confusion):
    for x, y in train_loader:
        x = x.unsqueeze(1)
        y_hat, hidden = rnn(x, h0)
        y_hat = y_hat[y_hat.size()[0] - 1]

        category_i = y
        guess_i = torch.argmax(y_hat, 1)

        confusion[category_i][guess_i] += 1
        break

# Normalize by dividing every row by its sum
for i in range(n_categories):
    confusion[i] = confusion[i] / confusion[i].sum()

# Set up plot
fig = plt.figure()
ax = fig.add_subplot(111)
cax = ax.matshow(confusion.numpy())
fig.colorbar(cax)

# Set up axes
ax.set_xticklabels([''] + all_categories, rotation=90)
ax.set_yticklabels([''] + all_categories)

# Force label at every tick
ax.xaxis.set_major_locator(ticker.MultipleLocator(1))
ax.yaxis.set_major_locator(ticker.MultipleLocator(1))

# sphinx_gallery_thumbnail_number = 2
plt.show()

"""Act on user input"""
while True:
    user_input = input("Enter a name: ")
    output = predict(user_input)
    print(all_categories[output])


confusion matrix:

Which seems to suggest it’s perfectly fine. I’m not shocked Scrottisch and English aren’t that easy to separate.
I’d love to get some more general feedback on my code, especially the pytorch parts. Is there anything I did in some uconventional way, or something I could have done better?