OK, in that case it might be more efficient if you only loop through all the images once and build a mapping from id to the precise filepath for each id, then just look this up when you want to do __getitem__.
For example, you can add something like
self.id_to_path = dict()
for dirpath, _, filenames in os.walk(root):
    for filename in filenames:
        id, ext = os.path.basename(filename).splitext()
        if ext == '.png':
            # TODO: might want to convert to integer depending on id type in dataframe
            self.id_to_path[id] = os.path.join(dirpath, filename)

Then in your __getitem__ just lookup the proper file:
    def __getitem__(self, index):
        """Returns one data pair (image and caption)."""
        row = self.df.iloc[index].squeeze()
        id = row.image_id
        image = self.id_to_path[id]
        image = Image.open(image).convert('RGB')    
        caption = row.InChI # here need caption to match image_id
        tokens = str(caption).lower().split()
        target = []
        target.append(vocab.stoi['<start>'])
        target.extend([vocab.stoi[token] for token in tokens])
        target.append(vocab.stoi['<end>'])
        target = torch.Tensor(target).long()
        return image, target, caption