thank you so much.  I saw that the encoder and decoder where inside the training loop.  That wasn’t necessary.  Now it appears to be training correctly on the images.
def training_step(n_epochs, data, encoder, decoder, optimizer, criterion):
    for epoch in range(n_epochs):
        #if epoch == 5: optimizer = torch.optim.AdamW(params, lr=1e-4)
        N = len(trn_dl)
        encoder.train()
        decoder.train()
        for i, data in enumerate(trn_dl):
            images, captions, lengths = data
            #images = images.to(device)
            #captions = captions.to(device)
            targets = pack_padded_sequence(captions, lengths.cpu(), batch_first=True)[0]
            features = encoder(images)
            outputs = decoder(features, captions, lengths)
            trn_loss = criterion(outputs, targets)
            decoder.zero_grad()
            encoder.zero_grad()
            #trn_loss.backward()
            optimizer.step()
            pos = epoch + (1+i)/N
            log.record(pos=pos, trn_loss=trn_loss, end='\r')
     

        N = len(val_dl)
        for i, data in enumerate(val_dl):
            val_loss = validate_batch(data, encoder, decoder, criterion)
            #val_loss.detach().clone()
            val_loss = validate_batch(data, encoder, decoder, criterion, batch_size)
            pos = epoch + (1+i)/N
            log.record(pos=pos, val_loss=val_loss, end='\r')
        #    torch.cuda.empty_cache()

        #log.report_avgs(epoch+1)
    #return log.plot_epochs(log=True)

training_step(n_epochs, data, encoder, decoder, optimizer, criterion)