Creating a Custom Heuristic Function Optimizer and Loss¶
We will create a heuristic function that is optimized via stochastic gradient descent with momentum and that has an asymmetric loss that penalizes overestimation more than underestimation.
In the directory in which you run deepxube, create a heuristics/resnet_fc_tut.py file.
DeepXube automatically looks in the heuristics/ folder to see what is registered. This file will be explained part-by-part.
from typing import List, Type, Tuple, Optional
import torch
from torch import nn, Tensor
import torch.optim as optim
from torch.optim import Optimizer
from deepxube.base.factory import DelimParser
from deepxube.base.nnet_input import FlatIn
from deepxube.base.heuristic import HeurNNet
from deepxube.nnet.pytorch_models import FullyConnectedModel, ResnetModel, OneHot
from deepxube.factories.heuristic_factory import heuristic_factory
# start registration
@heuristic_factory.register_class("resnet_fc_asym")
class ResnetFCHeur(HeurNNet[FlatIn]):
@staticmethod
def nnet_input_type() -> Type[FlatIn]:
return FlatIn
def __init__(self, nnet_input: FlatIn, out_dim: int, q_fix: bool, res_dim: int = 1000, num_blocks: int = 4, batch_norm: bool = False,
lr: float = 0.001, momentum: float = 0.5, over_w: float = 1.0):
super().__init__(nnet_input, out_dim, q_fix)
self.lr = lr
self.momentum: float = momentum
self.lr_curr: float = self.lr
self.over_w: float = over_w
# one hots
self.one_hots: nn.ModuleList = nn.ModuleList()
input_dim_tot: int = 0
input_dims, one_hot_depths = self.nnet_input.get_input_info()
for input_dim, one_hot_depth in zip(input_dims, one_hot_depths, strict=True):
assert one_hot_depth >= 1
self.one_hots.append(OneHot(one_hot_depth, True))
input_dim_tot += input_dim * one_hot_depth
# res net
self.res_dim: int = res_dim
def res_block_init() -> nn.Module:
return FullyConnectedModel(res_dim, [res_dim] * 2, ["RELU", "LINEAR"], batch_norms=[batch_norm] * 2)
self.heur = nn.Sequential(
nn.Linear(input_dim_tot, res_dim),
ResnetModel(res_block_init, num_blocks, "RELU"),
nn.Linear(res_dim, self.out_dim)
)
# end init
# start forward
def _forward(self, inputs: List[Tensor]) -> Tensor:
inputs_oh: List[Tensor] = [one_hot(input_i) for input_i, one_hot in zip(inputs, self.one_hots)]
x: Tensor = self.heur(torch.cat(inputs_oh, dim=1))
return x
# end forward
# start optim
def get_optimizer(self) -> Optimizer:
return optim.SGD(self.parameters(), lr=self.lr, momentum=self.momentum)
def update_optimizer(self, optimizer: Optimizer, train_itr: int) -> None:
self.lr_curr: float = self.lr / ((train_itr // 100) + 1)
for param_group in optimizer.param_groups:
param_group['lr'] = self.lr_curr
# end optim
# start loss
def get_loss_and_info(self, fwd_tr_tensors: List[Tensor], get_info: bool) -> Tuple[Tensor, Optional[str]]:
ctgs_nnet: Tensor = fwd_tr_tensors[0]
ctgs_targ: Tensor = fwd_tr_tensors[1]
err = ctgs_nnet - ctgs_targ
sq_err = err ** 2
sq_err_w = (self.over_w * sq_err * (err > 0.0)) + (sq_err * (err <= 0.0))
loss = sq_err_w.mean()
info: Optional[str] = None
if get_info:
info = f"targ_ctg: {ctgs_targ.mean().item():.2f}, nnet_ctg: {ctgs_nnet.mean().item():.2f}, lr: {self.lr_curr:.2E}"
return loss, info
# end loss
# start repr
def __repr__(self) -> str:
repr_str: str = super().__repr__()
repr_str = f"{repr_str}\nOver est weight: {self.over_w}"
return repr_str
# end repr
# start parser
@heuristic_factory.register_parser("resnet_fc_asym")
class ResnetFCParser(DelimParser):
def __init__(self) -> None:
super().__init__()
self.add_argument("H", "res_dim", int, "dimensionality of hidden layers in residual blocks")
self.add_argument("B", "num_blocks", int, "number of residual blocks")
self.add_argument("bn", "batch_norm", None, "Batch normalization")
self.add_argument("O", "over_w", float, "weight for overestimation")
@property
def delim(self) -> str:
return "_"
# end parser
Tip
For heuristics that are specific to a domain, it is best to define and register them in the domain file, itself. However, for heuristics that
can be re-used across domains, it is best to define and register them in a separate file in the heuristics/ folder.
Tip
Since the heuristic is registered, we should be able to see “resnet_fc_asym” with
deepxube heuristic_info after it is put in your heuristics/ folder.
More specific information can be obtained about the heuristic with
deepxube heuristic_info --name resnet_fc_asym
Registration, Initialization, and Forward¶
The heuristic function takes one-dimensional data, applies the specified one-hot transformation to it, and feeds this data to a fully-connected residual neural network.
@heuristic_factory.register_class("resnet_fc_asym")
class ResnetFCHeur(HeurNNet[FlatIn]):
@staticmethod
def nnet_input_type() -> Type[FlatIn]:
return FlatIn
def __init__(self, nnet_input: FlatIn, out_dim: int, q_fix: bool, res_dim: int = 1000, num_blocks: int = 4, batch_norm: bool = False,
lr: float = 0.001, momentum: float = 0.5, over_w: float = 1.0):
super().__init__(nnet_input, out_dim, q_fix)
self.lr = lr
self.momentum: float = momentum
self.lr_curr: float = self.lr
self.over_w: float = over_w
# one hots
self.one_hots: nn.ModuleList = nn.ModuleList()
input_dim_tot: int = 0
input_dims, one_hot_depths = self.nnet_input.get_input_info()
for input_dim, one_hot_depth in zip(input_dims, one_hot_depths, strict=True):
assert one_hot_depth >= 1
self.one_hots.append(OneHot(one_hot_depth, True))
input_dim_tot += input_dim * one_hot_depth
# res net
self.res_dim: int = res_dim
def res_block_init() -> nn.Module:
return FullyConnectedModel(res_dim, [res_dim] * 2, ["RELU", "LINEAR"], batch_norms=[batch_norm] * 2)
self.heur = nn.Sequential(
nn.Linear(input_dim_tot, res_dim),
ResnetModel(res_block_init, num_blocks, "RELU"),
nn.Linear(res_dim, self.out_dim)
)
# end init
# start forward
def _forward(self, inputs: List[Tensor]) -> Tensor:
inputs_oh: List[Tensor] = [one_hot(input_i) for input_i, one_hot in zip(inputs, self.one_hots)]
x: Tensor = self.heur(torch.cat(inputs_oh, dim=1))
return x
Important
DeepXube expects the first three arguments, nnet_input: FlatIn, out_dim: int, q_fix: bool to have these exact names so the
neural network can be properly initialized.
Custom Optimizer¶
Instead of using the default Adam optimizer, we implement stochastic gradient descent with momentum. Instead of using the default
exponential learning rate decay, we implement a step decay. This is done by overriding deepxube.base.heuristic.DeepXubeNNet.get_optimizer
and deepxube.base.heuristic.DeepXubeNNet.update_optimizer.
def get_optimizer(self) -> Optimizer:
return optim.SGD(self.parameters(), lr=self.lr, momentum=self.momentum)
def update_optimizer(self, optimizer: Optimizer, train_itr: int) -> None:
self.lr_curr: float = self.lr / ((train_itr // 100) + 1)
for param_group in optimizer.param_groups:
param_group['lr'] = self.lr_curr
Custom Loss¶
By overriding deepxube.base.heuristic.HeurNNet.get_loss_and_info, an asymmetric loss can be implemented. Custom information that
periodically gets printed to the screen can also be
def get_loss_and_info(self, fwd_tr_tensors: List[Tensor], get_info: bool) -> Tuple[Tensor, Optional[str]]:
ctgs_nnet: Tensor = fwd_tr_tensors[0]
ctgs_targ: Tensor = fwd_tr_tensors[1]
err = ctgs_nnet - ctgs_targ
sq_err = err ** 2
sq_err_w = (self.over_w * sq_err * (err > 0.0)) + (sq_err * (err <= 0.0))
loss = sq_err_w.mean()
info: Optional[str] = None
if get_info:
info = f"targ_ctg: {ctgs_targ.mean().item():.2f}, nnet_ctg: {ctgs_nnet.mean().item():.2f}, lr: {self.lr_curr:.2E}"
return loss, info
Representation¶
Since the overestimation amount is a parameter given to the class constructor, we can modify the __repr__ function to also print this
information.
def __repr__(self) -> str:
repr_str: str = super().__repr__()
repr_str = f"{repr_str}\nOver est weight: {self.over_w}"
return repr_str
Tip
The neural network representation is printed before training to make it easier to determine what neural network architecture was used across different runs.
Parser¶
The parser parses the overestimation penalty along with the dimensionality of the residual network, number of blocks, and whether or not to perform batch normalization.
@heuristic_factory.register_parser("resnet_fc_asym")
class ResnetFCParser(DelimParser):
def __init__(self) -> None:
super().__init__()
self.add_argument("H", "res_dim", int, "dimensionality of hidden layers in residual blocks")
self.add_argument("B", "num_blocks", int, "number of residual blocks")
self.add_argument("bn", "batch_norm", None, "Batch normalization")
self.add_argument("O", "over_w", float, "weight for overestimation")
@property
def delim(self) -> str:
return "_"
Training¶
deepxube train --domain cube3 --heur resnet_fc_asym.200H_2B_bn_20o --heur_type V --pathfind graph_v --step_max 100 --up_itrs 100 --search_itrs 50 --backup -1 --procs 2 --batch_size 200 --max_itrs 5000 --dir tutorial/heur_asym_loss/models/ --display 50
--display 50: to display the neural network training information implemented in get_loss_and_info every 50 iterations.
Using deepxube train_summary --dir tutorial/heur_asym_loss/models/ we can see that the neural network output is below the target
the majority of the time.