Thanks so much, @ptrblck! I spent today debugging once again by rewriting the entire training pipeline from scratch and testing on incrementally more meaningful sets of data, and ended up finding the problem. It was very sneaky.
train.py from this starter code was actually fine, but in eval.py, net.eval() was called. However, net.train() was called only at the beginning of each epoch, while net.eval() was called every batch. So there was only meaningful training going on for one batch per epoch - no wonder!
Thanks for your help and quick response on this problem! It’s a worthwhile lesson learned. I’ll probably throw in a pull request.