Thanks @vfdev-5. That makes sense.
I added a model, loss, etc to the above code:
import torch
from torch import nn, optim
import torch.nn.functional as F
from torchtext.data import Field, BucketIterator
from torchtext.datasets import IMDB

# load IMDB reviews and ratings data 
# (built into torchtext similar to MNIST with torchvision)

## define fields in data
TEXT = Field(lower=True)
LABEL = Field(sequential=False)

## import datasets
train, test = IMDB.splits(TEXT, LABEL, root="../data")
TEXT.build_vocab(train, min_freq=20)
LABEL.build_vocab(train)
train_iter, test_iter = BucketIterator.splits((train, test),
                                              batch_size=100,
                                              repeat=False)

nwords = len(TEXT.vocab)
ntags = 2


class BOW(nn.Module):
    """Simple bag of words model"""
    def __init__(self, nwords, ntags):
        super(BOW, self).__init__()
        self.bias = nn.Parameter(torch.randn(ntags))
        self.embeddings = nn.Embedding(nwords, ntags)

    def forward(self, input):
        output = self.embeddings(input).sum(dim=1)
        output = output + self.bias

        return F.log_softmax(output, dim=1)

model = BOW(nwords, ntags)
opt = optim.SGD(model.parameters(), lr=0.01)
criterion = nn.NLLLoss()

for epoch in range(2):
    for batch_idx, batch in enumerate(train_iter):
        model.train()
        opt.zero_grad()
        x, y = batch.text.t(), batch.label - 1 # only difference from `_update`
        y_pred = model(x)
        loss = criterion(y_pred, y)
        loss.backward()
        opt.step()

        if batch_idx % 10 == 0:
            print("Epoch %d batch %d" % (epoch, batch_idx)) 

The only difference from the “typical” update step that I see is the transformation of the batch to input and output.