Skip to content

Commit ec857a5

Browse files
authored
Add support for backwards actions in MolBuildingEnvContext (#100)
This PR: - adds support for backward actions in `MolBuildingEnvContext`, - adds tests for `MolBuildingEnvContext` that check that backwards masks are correct, - adds a toy atom environment where the reward is simply the number of rings in a carbon-only molecule of up to 6 atoms. - fixes a bug in `GraphBuildingEnv.parent`, whereby 1-node graphs with attributes would have their parents misenumerated (which hadn't manifested itself because until now we didn't have contexts with more than 1 node attribute). * add backward mask support + small ring task * more comments and implementation notes
1 parent 152b18f commit ec857a5

5 files changed

Lines changed: 254 additions & 26 deletions

File tree

docs/implementation_notes.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,21 @@ We separate experiment concerns in four categories:
1616
- The Trainer class is responsible for instanciating everything, and running the training & testing loop
1717

1818
Typically one would setup a new experiment by creating a class that inherits from `GFNTask` and a class that inherits from `GFNTrainer`. To implement a new MDP, one would create a class that inherits from `GraphBuildingEnvContext`.
19+
20+
21+
## Graphs
22+
23+
This library is built around the idea of generating graphs. We use the `networkx` library to represent graphs, and we use the `torch_geometric` library to represent graphs as tensors for the models. There is a fair amount of code that is dedicated to converting between the two representations.
24+
25+
Some notes:
26+
- graphs are (for now) assumed to be _undirected_. This is encoded for `torch_geometric` by duplicating the edges (contiguously) in both directions. Models still only produce one logit(-row) per edge, so the policy is still assumed to operate on undirected graphs.
27+
- When converting from `GraphAction`s (nx) to so-called `aidx`s, the `aidx`s are encoding-bound, i.e. they point to specific rows and columns in the torch encoding.
28+
29+
30+
### Graph policies & graph action categoricals
31+
32+
The code contains a specific categorical distribution type for graph actions, `GraphActionCategorical`. This class contains logic to sample from concatenated sets of logits accross a minibatch.
33+
34+
Consider for example the `AddNode` and `SetEdgeAttr` actions, one applies to nodes and one to edges. An efficient way to produce logits for these actions would be to take the node/edge embeddings and project them (e.g. via an MLP) to a `(n_nodes, n_node_actions)` and `(n_edges, n_edge_actions)` tensor respectively. We thus obtain a list of tensors representing the logits of different actions, but logits are mixed between graphs in the minibatch, so one cannot simply apply a `softmax` operator on the tensor.
35+
36+
The `GraphActionCategorical` class handles this and can be used to compute various other things, such as entropy, log probabilities, and so on; it can also be used to sample from the distribution.

src/gflownet/envs/graph_building_env.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -271,9 +271,9 @@ def add_parent(a, new_g):
271271
GraphAction(GraphActionType.AddNode, source=anchor, value=g.nodes[i]["v"]),
272272
new_g,
273273
)
274-
if len(g.nodes) == 1:
274+
if len(g.nodes) == 1 and len(g.nodes[i]) == 1:
275275
# The final node is degree 0, need this special case to remove it
276-
# and end up with S0, the empty graph root
276+
# and end up with S0, the empty graph root (but only if it has no attrs except 'v')
277277
add_parent(
278278
GraphAction(GraphActionType.AddNode, source=0, value=g.nodes[i]["v"]),
279279
graph_without_node(g, i),

src/gflownet/envs/mol_building_env.py

Lines changed: 101 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@
88
from rdkit.Chem import Mol
99
from rdkit.Chem.rdchem import BondType, ChiralType
1010

11-
from gflownet.envs.graph_building_env import Graph, GraphAction, GraphActionType, GraphBuildingEnvContext
11+
from gflownet.envs.graph_building_env import (
12+
Graph,
13+
GraphAction,
14+
GraphActionType,
15+
GraphBuildingEnvContext,
16+
graph_without_edge,
17+
)
1218
from gflownet.utils.graphs import random_walk_probs
1319

1420
DEFAULT_CHIRAL_TYPES = [ChiralType.CHI_UNSPECIFIED, ChiralType.CHI_TETRAHEDRAL_CW, ChiralType.CHI_TETRAHEDRAL_CCW]
@@ -77,19 +83,22 @@ def __init__(
7783
# The size of the input vector for each atom
7884
self.atom_attr_size = sum(len(i) for i in self.atom_attr_values.values())
7985
self.atom_attrs = sorted(self.atom_attr_values.keys())
86+
# 'v' is set separately when creating the node, so there's no point in having a SetNodeAttr logit for it
87+
self.settable_atom_attrs = [i for i in self.atom_attrs if i != "v"]
8088
# The beginning position within the input vector of each attribute
8189
self.atom_attr_slice = [0] + list(np.cumsum([len(self.atom_attr_values[i]) for i in self.atom_attrs]))
8290
# The beginning position within the logit vector of each attribute
83-
num_atom_logits = [len(self.atom_attr_values[i]) - 1 for i in self.atom_attrs]
91+
num_atom_logits = [len(self.atom_attr_values[i]) - 1 for i in self.settable_atom_attrs]
8492
self.atom_attr_logit_slice = {
8593
k: (s, e)
86-
for k, s, e in zip(self.atom_attrs, [0] + list(np.cumsum(num_atom_logits)), np.cumsum(num_atom_logits))
94+
for k, s, e in zip(
95+
self.settable_atom_attrs, [0] + list(np.cumsum(num_atom_logits)), np.cumsum(num_atom_logits)
96+
)
8797
}
8898
# The attribute and value each logit dimension maps back to
8999
self.atom_attr_logit_map = [
90100
(k, v)
91-
for k in self.atom_attrs
92-
if k != "v"
101+
for k in self.settable_atom_attrs
93102
# index 0 is skipped because it is the default value
94103
for v in self.atom_attr_values[k][1:]
95104
]
@@ -147,12 +156,21 @@ def __init__(
147156
GraphActionType.AddEdge,
148157
GraphActionType.SetEdgeAttr,
149158
]
159+
self.bck_action_type_order = [
160+
GraphActionType.RemoveNode,
161+
GraphActionType.RemoveNodeAttr,
162+
GraphActionType.RemoveEdge,
163+
GraphActionType.RemoveEdgeAttr,
164+
]
150165
self.device = torch.device("cpu")
151166

152167
def aidx_to_GraphAction(self, g: gd.Data, action_idx: Tuple[int, int, int], fwd: bool = True):
153168
"""Translate an action index (e.g. from a GraphActionCategorical) to a GraphAction"""
154169
act_type, act_row, act_col = [int(i) for i in action_idx]
155-
t = self.action_type_order[act_type]
170+
if fwd:
171+
t = self.action_type_order[act_type]
172+
else:
173+
t = self.bck_action_type_order[act_type]
156174
if t is GraphActionType.Stop:
157175
return GraphAction(t)
158176
elif t is GraphActionType.AddNode:
@@ -164,12 +182,34 @@ def aidx_to_GraphAction(self, g: gd.Data, action_idx: Tuple[int, int, int], fwd:
164182
a, b = g.non_edge_index[:, act_row]
165183
return GraphAction(t, source=a.item(), target=b.item())
166184
elif t is GraphActionType.SetEdgeAttr:
167-
a, b = g.edge_index[:, act_row * 2] # Edges are duplicated to get undirected GNN, deduplicated for logits
185+
# In order to form an undirected graph for torch_geometric, edges are duplicated, in order (i.e.
186+
# g.edge_index = [[a,b], [b,a], [c,d], [d,c], ...].T), but edge logits are not. So to go from one
187+
# to another we can safely divide or multiply by two.
188+
a, b = g.edge_index[:, act_row * 2]
168189
attr, val = self.bond_attr_logit_map[act_col]
169190
return GraphAction(t, source=a.item(), target=b.item(), attr=attr, value=val)
191+
elif t is GraphActionType.RemoveNode:
192+
return GraphAction(t, source=act_row)
193+
elif t is GraphActionType.RemoveNodeAttr:
194+
attr = self.settable_atom_attrs[act_col]
195+
return GraphAction(t, source=act_row, attr=attr)
196+
elif t is GraphActionType.RemoveEdge:
197+
a, b = g.edge_index[:, act_row * 2] # see note above about edge_index
198+
return GraphAction(t, source=a.item(), target=b.item())
199+
elif t is GraphActionType.RemoveEdgeAttr:
200+
a, b = g.edge_index[:, act_row * 2] # see note above about edge_index
201+
attr = self.bond_attrs[act_col]
202+
return GraphAction(t, source=a.item(), target=b.item(), attr=attr)
170203

171204
def GraphAction_to_aidx(self, g: gd.Data, action: GraphAction) -> Tuple[int, int, int]:
172205
"""Translate a GraphAction to an index tuple"""
206+
for u in [self.action_type_order, self.bck_action_type_order]:
207+
if action.action in u:
208+
type_idx = u.index(action.action)
209+
break
210+
else:
211+
raise ValueError(f"Unknown action type {action.action}")
212+
173213
if action.action is GraphActionType.Stop:
174214
row = col = 0
175215
elif action.action is GraphActionType.AddNode:
@@ -191,17 +231,33 @@ def GraphAction_to_aidx(self, g: gd.Data, action: GraphAction) -> Tuple[int, int
191231
).argmax()
192232
col = 0
193233
elif action.action is GraphActionType.SetEdgeAttr:
194-
# Here the edges are duplicated, both (i,j) and (j,i) are in edge_index
195-
# so no need for a double check.
196-
# row = ((g.edge_index.T == torch.tensor([(action.source, action.target)])).prod(1) +
197-
# (g.edge_index.T == torch.tensor([(action.target, action.source)])).prod(1)).argmax()
234+
# In order to form an undirected graph for torch_geometric, edges are duplicated, in order (i.e.
235+
# g.edge_index = [[a,b], [b,a], [c,d], [d,c], ...].T), but edge logits are not. So to go from one
236+
# to another we can safely divide or multiply by two.
198237
row = (g.edge_index.T == torch.tensor([(action.source, action.target)])).prod(1).argmax()
199-
# Because edges are duplicated but logits aren't, divide by two
200238
row = row.div(2, rounding_mode="floor") # type: ignore
201239
col = (
202240
self.bond_attr_values[action.attr].index(action.value) - 1 + self.bond_attr_logit_slice[action.attr][0]
203241
)
204-
type_idx = self.action_type_order.index(action.action)
242+
elif action.action is GraphActionType.RemoveNode:
243+
row = action.source
244+
col = 0
245+
elif action.action is GraphActionType.RemoveNodeAttr:
246+
row = action.source
247+
col = self.settable_atom_attrs.index(action.attr)
248+
elif action.action is GraphActionType.RemoveEdge:
249+
row = ((g.edge_index.T == torch.tensor([(action.source, action.target)])).prod(1)).argmax()
250+
# In order to form an undirected graph for torch_geometric, edges are duplicated, in order (i.e.
251+
# g.edge_index = [[a,b], [b,a], [c,d], [d,c], ...].T), but edge logits are not. So to go from one
252+
# to another we can safely divide or multiply by two.
253+
row = int(row) // 2
254+
col = 0
255+
elif action.action is GraphActionType.RemoveEdgeAttr:
256+
row = (g.edge_index.T == torch.tensor([(action.source, action.target)])).prod(1).argmax()
257+
row = row.div(2, rounding_mode="floor") # type: ignore
258+
col = self.bond_attrs.index(action.attr)
259+
else:
260+
raise ValueError(f"Unknown action type {action.action}")
205261
return (type_idx, int(row), int(col))
206262

207263
def graph_to_Data(self, g: Graph) -> gd.Data:
@@ -211,25 +267,43 @@ def graph_to_Data(self, g: Graph) -> gd.Data:
211267
add_node_mask = torch.ones((x.shape[0], self.num_new_node_values))
212268
if self.max_nodes is not None and len(g.nodes) >= self.max_nodes:
213269
add_node_mask *= 0
270+
remove_node_mask = torch.zeros((x.shape[0], 1)) + (1 if len(g) == 0 else 0)
271+
remove_node_attr_mask = torch.zeros((x.shape[0], len(self.settable_atom_attrs)))
272+
214273
explicit_valence = {}
215274
max_valence = {}
216275
set_node_attr_mask = torch.ones((x.shape[0], self.num_node_attr_logits))
217276
if not len(g.nodes):
218277
set_node_attr_mask *= 0
219278
for i, n in enumerate(g.nodes):
220279
ad = g.nodes[n]
280+
if g.degree(n) <= 1 and len(ad) == 1 and all([len(g[n][neigh]) == 0 for neigh in g.neighbors(n)]):
281+
# If there's only the 'v' key left and the node is a leaf, and the edge that connect to the node have
282+
# no attributes set, we can remove it
283+
remove_node_mask[i] = 1
221284
for k, sl in zip(self.atom_attrs, self.atom_attr_slice):
285+
# idx > 0 means that the attribute is not the default value
222286
idx = self.atom_attr_values[k].index(ad[k]) if k in ad else 0
223287
x[i, sl + idx] = 1
224-
# If the attribute is already there, mask out logits
225-
# (or if the attribute is a negative attribute and has been filled)
288+
if k == "v":
289+
continue
290+
# If the attribute
291+
# - is already there (idx > 0),
292+
# - or the attribute is a negative attribute and has been filled
293+
# - or the attribute is a negative attribute and is not fillable (i.e. not a key of ad)
294+
# then mask forward logits.
295+
# For backward logits, positively mask if the attribute is there (idx > 0).
226296
if k in self.negative_attrs:
227297
if k in ad and idx > 0 or k not in ad:
228298
s, e = self.atom_attr_logit_slice[k]
229299
set_node_attr_mask[i, s:e] = 0
300+
# We don't want to make the attribute removable if it's not fillable (i.e. not a key of ad)
301+
if k in ad:
302+
remove_node_attr_mask[i, self.settable_atom_attrs.index(k)] = 1
230303
elif k in ad:
231304
s, e = self.atom_attr_logit_slice[k]
232305
set_node_attr_mask[i, s:e] = 0
306+
remove_node_attr_mask[i, self.settable_atom_attrs.index(k)] = 1
233307
# Account for charge and explicit Hs in atom as limiting the total valence
234308
max_atom_valence = self._max_atom_valence[ad.get("fill_wildcard", None) or ad["v"]]
235309
# Special rule for Nitrogen
@@ -256,8 +330,14 @@ def graph_to_Data(self, g: Graph) -> gd.Data:
256330
s, e = self.atom_attr_logit_slice["expl_H"]
257331
set_node_attr_mask[i, s:e] = 0
258332

333+
remove_edge_mask = torch.zeros((len(g.edges), 1))
334+
for i, (u, v) in enumerate(g.edges):
335+
if g.degree(u) > 1 and g.degree(v) > 1:
336+
if nx.algorithms.is_connected(graph_without_edge(g, (u, v))):
337+
remove_edge_mask[i] = 1
259338
edge_attr = torch.zeros((len(g.edges) * 2, self.num_edge_dim))
260339
set_edge_attr_mask = torch.zeros((len(g.edges), self.num_edge_attr_logits))
340+
remove_edge_attr_mask = torch.zeros((len(g.edges), len(self.bond_attrs)))
261341
for i, e in enumerate(g.edges):
262342
ad = g.edges[e]
263343
for k, sl in zip(self.bond_attrs, self.bond_attr_slice):
@@ -267,6 +347,7 @@ def graph_to_Data(self, g: Graph) -> gd.Data:
267347
if k in ad: # If the attribute is already there, mask out logits
268348
s, e = self.bond_attr_logit_slice[k]
269349
set_edge_attr_mask[i, s:e] = 0
350+
remove_edge_attr_mask[i, self.bond_attrs.index(k)] = 1
270351
# Check which bonds don't bust the valence of their atoms
271352
if "type" not in ad: # Only if type isn't already set
272353
sl, _ = self.bond_attr_logit_slice["type"]
@@ -293,11 +374,15 @@ def is_ok_non_edge(e):
293374
edge_index,
294375
edge_attr,
295376
non_edge_index=non_edge_index,
296-
stop_mask=torch.ones(1, 1) if len(g) > 0 else torch.zeros(1, 1),
377+
stop_mask=torch.ones((1, 1)) * (len(g.nodes) > 0), # Can only stop if there's at least a node
297378
add_node_mask=add_node_mask,
298379
set_node_attr_mask=set_node_attr_mask,
299380
add_edge_mask=torch.ones((non_edge_index.shape[1], 1)), # Already filtered by is_ok_non_edge
300381
set_edge_attr_mask=set_edge_attr_mask,
382+
remove_node_mask=remove_node_mask,
383+
remove_node_attr_mask=remove_node_attr_mask,
384+
remove_edge_mask=remove_edge_mask,
385+
remove_edge_attr_mask=remove_edge_attr_mask,
301386
)
302387
if self.num_rw_feat > 0:
303388
data.x = torch.cat([data.x, random_walk_probs(data, self.num_rw_feat, skip_odd=True)], 1)

src/gflownet/tasks/make_rings.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import os
2+
import socket
3+
from typing import Dict, List, Tuple, Union
4+
5+
import numpy as np
6+
import torch
7+
from rdkit import Chem
8+
from rdkit.Chem.rdchem import Mol as RDMol
9+
from torch import Tensor
10+
11+
from gflownet.config import Config
12+
from gflownet.envs.mol_building_env import MolBuildingEnvContext
13+
from gflownet.online_trainer import StandardOnlineTrainer
14+
from gflownet.trainer import FlatRewards, GFNTask, RewardScalar
15+
16+
17+
class MakeRingsTask(GFNTask):
18+
"""A toy task where the reward is the number of rings in the molecule."""
19+
20+
def __init__(
21+
self,
22+
rng: np.random.Generator,
23+
):
24+
self.rng = rng
25+
26+
def flat_reward_transform(self, y: Union[float, Tensor]) -> FlatRewards:
27+
return FlatRewards(y)
28+
29+
def sample_conditional_information(self, n: int, train_it: int) -> Dict[str, Tensor]:
30+
return {"beta": torch.ones(n), "encoding": torch.ones(n, 1)}
31+
32+
def cond_info_to_logreward(self, cond_info: Dict[str, Tensor], flat_reward: FlatRewards) -> RewardScalar:
33+
scalar_logreward = torch.as_tensor(flat_reward).squeeze().clamp(min=1e-30).log()
34+
return RewardScalar(scalar_logreward.flatten())
35+
36+
def compute_flat_rewards(self, mols: List[RDMol]) -> Tuple[FlatRewards, Tensor]:
37+
rs = torch.tensor([m.GetRingInfo().NumRings() for m in mols]).float()
38+
return FlatRewards(rs.reshape((-1, 1))), torch.ones(len(mols)).bool()
39+
40+
41+
class MakeRingsTrainer(StandardOnlineTrainer):
42+
def set_default_hps(self, cfg: Config):
43+
cfg.hostname = socket.gethostname()
44+
cfg.num_workers = 8
45+
cfg.algo.global_batch_size = 64
46+
cfg.algo.offline_ratio = 0
47+
cfg.model.num_emb = 128
48+
cfg.model.num_layers = 4
49+
50+
cfg.algo.method = "TB"
51+
cfg.algo.max_nodes = 6
52+
cfg.algo.sampling_tau = 0.9
53+
cfg.algo.illegal_action_logreward = -75
54+
cfg.algo.train_random_action_prob = 0.0
55+
cfg.algo.valid_random_action_prob = 0.0
56+
cfg.algo.tb.do_parameterize_p_b = True
57+
58+
cfg.replay.use = False
59+
60+
def setup_task(self):
61+
self.task = MakeRingsTask(rng=self.rng)
62+
63+
def setup_env_context(self):
64+
self.ctx = MolBuildingEnvContext(
65+
["C"],
66+
charges=[0], # disable charge
67+
chiral_types=[Chem.rdchem.ChiralType.CHI_UNSPECIFIED], # disable chirality
68+
num_rw_feat=0,
69+
max_nodes=self.cfg.algo.max_nodes,
70+
num_cond_dim=1,
71+
)
72+
73+
74+
def main():
75+
hps = {
76+
"log_dir": "./logs/debug_run_mr4",
77+
"device": "cuda",
78+
"num_training_steps": 10_000,
79+
"num_workers": 8,
80+
"algo": {"tb": {"do_parameterize_p_b": True}},
81+
}
82+
os.makedirs(hps["log_dir"], exist_ok=True)
83+
84+
trial = MakeRingsTrainer(hps)
85+
trial.print_every = 1
86+
trial.run()
87+
88+
89+
if __name__ == "__main__":
90+
main()

0 commit comments

Comments
 (0)