gym_wikinav/envs/wikinav_env/web_graph.py (248 lines of code) (raw):
"""
Defines a common web graph navigation interface to WikiNav, Wikispeedia, etc.
"""
from collections import namedtuple
import os
import random
import sys
import numpy as np
from gym_wikinav.envs.wikinav_env.util import download_file
EmbeddedArticle = namedtuple("EmbeddedArticle", ["title", "embedding", "text"])
class EmbeddedWebGraph(object):
embedding_dim = 128
def __init__(self, articles, datasets, path_length, stop_sentinel=None):
self.articles = articles
self.datasets = {name: (all_paths, np.array(lengths))
for name, (all_paths, lengths) in datasets.items()}
self.path_length = path_length
assert "train" in self.datasets
assert "valid" in self.datasets
# Hack: use a random page as the "STOP" sentinel.
# Works in expectation. :)
if stop_sentinel is None:
stop_sentinel = np.random.choice(len(self.articles))
self.stop_sentinel = stop_sentinel
print("Stop sentinel: ", self.stop_sentinel,
self.articles[self.stop_sentinel].title)
self._eval_cursor = 0
def sample_paths(self, batch_size, is_training=True):
all_paths, lengths = self.datasets["train" if is_training else "valid"]
if is_training:
ids = np.random.choice(len(all_paths), size=batch_size)
else:
if self._eval_cursor >= len(all_paths) - 1:
self._eval_cursor = 0
ids = np.arange(self._eval_cursor,
min(len(all_paths),
self._eval_cursor + batch_size))
self._eval_cursor += batch_size
paths = [self._prepare_path(all_paths[idx]) for idx in ids]
return ids, paths, lengths[ids]
def get_num_paths(self, is_training=True):
return len(self.datasets["train" if is_training else "valid"][0])
def get_article_links(self, article_idx):
raise NotImplementedError
def get_article_title(self, article_idx):
if article_idx == self.stop_sentinel:
return "<STOP>"
return self.articles[article_idx].title
def get_relative_word_overlap(self, article1_idx, article2_idx):
"""
Get the proportion of words in `article1` that are also in `article2`.
"""
article1 = self.articles[article1_idx]
article2 = self.articles[article2_idx]
article1_types = set(article1.text)
if len(article1_types) == 0:
return 0.0
article2_types = set(article2.text)
return len(article1_types & article2_types) / float(len(article1_types))
def get_query_embeddings(self, path_ids):
raise NotImplementedError
def get_article_embeddings(self, article_ids):
raise NotImplementedError
def _prepare_path(self, path):
raise NotImplementedError
class EmbeddedWikispeediaGraph(EmbeddedWebGraph):
def __init__(self, data_path, path_length, emb_paths=None):
try:
import cPickle as pickle
except: import pickle
with open(data_path, "rb") as data_f:
data = pickle.load(data_f)
self._data = data
if emb_paths is not None:
embeddings = [np.load(emb_path)["arr_0"] for emb_path in emb_paths]
self.embedding_dim = embeddings[0].shape[1]
for other_embeddings in embeddings:
assert other_embeddings.shape == embeddings[0].shape
self.embeddings = embeddings
else:
print("=====================================================\n"
"WARNING: Using randomly generated article embeddings.\n"
"=====================================================",
file=sys.stderr)
# Random embeddings.
self.embedding_dim = 128 # fixed for now
shape = (len(data["articles"]), self.embedding_dim)
# Match Wikispeedia embedding distribution
embeddings = np.random.normal(scale=0.15, size=shape)
self.embeddings = [embeddings]
articles = [EmbeddedArticle(
article["name"], self.embeddings[0][i],
set(token.lower() for token in article["lead_tokens"]))
for i, article in enumerate(data["articles"])]
assert articles[0].title == "_Stop"
assert articles[1].title == "_Dummy"
stop_sentinel = 0
datasets = {}
for dataset_name, dataset in data["paths"].items():
paths, original_lengths, n_skipped = [], [], 0
for path in dataset:
if len(path["articles"]) > path_length - 1:
n_skipped += 1
continue
# Pad with STOP sentinel (every path gets at least one)
pad_length = max(0, path_length + 1 - len(path["articles"]))
original_length = len(path["articles"]) + 1
path = path["articles"] + [stop_sentinel] * pad_length
paths.append(path)
original_lengths.append(original_length)
print("%s set: skipped %i of %i paths due to length limit"
% (dataset_name, n_skipped, len(dataset)))
datasets[dataset_name] = (paths, np.array(original_lengths))
super(EmbeddedWikispeediaGraph, self).__init__(articles, datasets,
path_length,
stop_sentinel=stop_sentinel)
def get_article_links(self, article_idx):
return self._data["links"].get(article_idx, [self.stop_sentinel])
def get_query_embeddings(self, paths, embedding_set=0):
# Get the last non-STOP page in each corresponding path.
last_pages = [[idx for idx in path if idx != self.stop_sentinel][-1]
for path in paths]
return self.get_article_embeddings(last_pages,
embedding_set=embedding_set)
def get_article_embeddings(self, article_ids, embedding_set=0):
return self.embeddings[embedding_set][article_ids]
def _prepare_path(self, path):
return path
LOCAL_GRAPH_PATH = "wikispeedia.pkl"
LOCAL_EMBEDDINGS_PATH = "wikispeedia_embeddings.npz"
REMOTE_GRAPH_URL = "https://github.com/hans/wikispeedia/raw/master/data/wikispeedia.pkl"
REMOTE_EMBEDDINGS_URL = "https://github.com/hans/wikispeedia/raw/master/data/wikispeedia_embeddings.npz"
@classmethod
def get_default_graph(cls, path_length=10):
if hasattr(cls, "_default_graph"):
return cls._default_graph
# Load the built-in graph data, downloading if necessary.
script_dir = os.path.dirname(os.path.realpath(__file__))
graph_path = os.path.join(script_dir, cls.LOCAL_GRAPH_PATH)
if not os.path.exists(graph_path):
print("Downloading default Wikispeedia graph.", file=sys.stderr)
download_file(cls.REMOTE_GRAPH_URL, graph_path)
emb_path = os.path.join(script_dir, cls.LOCAL_EMBEDDINGS_PATH)
if not os.path.exists(emb_path):
print("Downloading default Wikispeedia embeddings.", file=sys.stderr)
download_file(cls.REMOTE_EMBEDDINGS_URL, emb_path)
graph = cls(graph_path, path_length, emb_paths=[emb_path])
cls._default_graph = graph
return graph
class Navigator(object):
def __init__(self, graph, beam_size, path_length):
self.graph = graph
self.beam_size = beam_size
self.path_length = path_length
assert self.graph.articles[1].title == "_Dummy", \
"Graph must have articles[1] == dummy article"
self._dummy_page = 1
print("Dummy page: ", self._dummy_page,
self.graph.get_article_title(self._dummy_page))
self._id, self._path, self._length = None, None, None
self.beam = None
def reset(self, is_training=True):
"""
Prepare a new navigation rollout.
"""
# TODO: Sample outside of the training set.
ids, paths, lengths = self.graph.sample_paths(1, is_training)
self._id, self._path, self._length = ids[0], paths[0], lengths[0]
self._cur_article_id = self._path[0]
self._target_id = self._path[self._length - 2]
self._on_target = False
self._success, self._stopped = False, False
self._num_steps = 0
self._reset(is_training)
self._prepare()
def _reset(self, is_training):
# For subclasses.
pass
def step(self, action):
"""
Make a navigation step with the given actions.
"""
self._step(action)
# Now cur_article_id contains the result of taking the actions
# specified.
stopped_now = self.cur_article_id == self.graph.stop_sentinel
self._stopped = self._stopped or stopped_now
# Did we just stop at the target page? (Use previous self._on_target
# before updating `on_target`)
success_now = self._on_target and stopped_now
self._success = self._success or success_now
self._on_target = self.cur_article_id == self._target_id
self._num_steps += 1
self._prepare()
def _step(self, action):
"""
For subclasses. Modify state using `action`. Metadata handled by this
superclass.
"""
self._cur_article_id = self.get_article_for_action(action)
@property
def cur_article_id(self):
return self._cur_article_id
@property
def gold_action(self):
"""
Return the gold navigation action for the current state.
"""
raise RuntimeError("Gold actions not defined for this navigator!")
@property
def target_id(self):
"""
Return target article ID.
"""
return self._target_id
@property
def on_target(self):
"""
Return True iff we are currently on the target page.
"""
return self.cur_article_id == self.target_id
@property
def gold_path_length(self):
"""
Return length of un-padded version of gold path (including stop
sentinel).
"""
raise RuntimeError("Gold paths not defined for this navigator!")
@property
def done(self):
"""
`True` if the traversal was manually stopped or if the path length has
been reached.
"""
return self._stopped or self._num_steps > self.path_length
@property
def success(self):
"""
`True` when the traversal has successfully reached the target.
"""
return self._success
def get_article_for_action(self, action):
"""
Get the article ID corresponding to an action ID on the beam.
"""
return self.beam[action]
def _get_candidates(self):
"""
Build a beam of candidate next-page IDs consisting of available links
on the current article.
NB: The candidate list returned may have a regular pattern, e.g. the
stop sentinel / filler candidates (for candidate lists which are smaller
than the beam size) may always be in the same position in the list.
Make sure to not build models (e.g. ones with output biases) that might
capitalize on this pattern.
Returns:
candidates: List of article IDs of length `self.beam_size`.
"""
all_links = self.graph.get_article_links(self.cur_article_id)
# Sample `beam_size - 1`; add the STOP sentinel
candidates = random.sample(all_links, min(self.beam_size - 1,
len(all_links)))
candidates.append(self.graph.stop_sentinel)
if len(candidates) < self.beam_size:
padding = [self._dummy_page] * (self.beam_size - len(candidates))
candidates.extend(padding)
return candidates
def _prepare(self):
"""
Prepare/update information about the current navigator state.
Should be called after reset / steps are taken.
"""
self.beam = self._get_candidates()
class OracleNavigator(Navigator):
def _reset(self, is_training):
self._cursor = 0
def _step(self, action):
# Ignore the action; we are following gold paths.
self._cursor += 1
@property
def cur_article_id(self):
if self._cursor < self._length:
return self._path[self._cursor]
return self.graph.stop_sentinel
@property
def gold_action(self):
return self._gold_action
@property
def gold_path_length(self):
return self._length
@property
def done(self):
return self._cursor >= self._length
def _get_candidates(self):
"""
Build a beam of candidate next-page IDs consisting of the valid
solution and other negatively-sampled candidate links on the page.
NB: The candidate list returned may have a regular pattern, e.g. the
stop sentinel / filler candidates (for candidate lists which are smaller
than the beam size) may always be in the same position in the list.
Make sure to not build models (e.g. ones with output biases) that might
capitalize on this pattern.
Returns:
candidates: List of article IDs of length `self.beam_size`.
The list is guaranteed to contain 1) the gold next page
according to the oracle trajectory and 2) the stop sentinel.
(Note that these two will make up just one candidate if the
valid next action is to stop.)
"""
# Retrieve gold next-page choice for this example
try:
gold_next_id = path[cursor + 1]
except IndexError:
# We are at the end of this path and ready to quit. Prepare a
# dummy beam that won't have any effect.
candidates = [self._dummy_page] * self.beam_size
self._gold_action = 0
return candidates
ids = self.graph.get_article_links(self.cur_article_id)
ids = [int(x) for x in ids if x != gold_next_id]
# Beam must be large enough to hold gold + STOP + a distractor
assert self.beam_size >= 3
gold_is_stop = gold_next_id == self.graph.stop_sentinel
# Number of distractors to sample
sample_size = self.beam_size - 1 if gold_is_stop \
else self.beam_size - 2
if len(ids) > sample_size:
ids = random.sample(ids, sample_size)
if len(ids) < sample_size:
ids += [self._dummy_page] * (sample_size - len(ids))
# Add the gold page.
ids = [gold_next_id] + ids
if not gold_is_stop:
ids += [self.graph.stop_sentinel]
random.shuffle(ids)
assert len(ids) == self.beam_size
self._gold_action = gold_next_id
return ids