Stop worrying about configs with Gin | by Alexander Bailey | Towards Data Science

We’ve all been there, you’re modelling away and one by one the hyper-parameters start adding up. At first, you ignore the problem and tell yourself that you’ll go back later to sort it out but inevitably don’t. There are loads of bad ways to tackle the problem of project configuration they include but are by no means limited to:
The really silly ways to configure your project:
Creating a list of variables at the top of your script that you change until you find ones that workMaybe you just define defaults for your functions and classes that you change on the fly
Creating a list of variables at the top of your script that you change until you find ones that work
Maybe you just define defaults for your functions and classes that you change on the fly
The less silly ways to configure your project:
Use a TOML or YAML file and pass a dictionary of config variables around with youUse a config script that sets environment variables that get read by your packages
Use a TOML or YAML file and pass a dictionary of config variables around with you
Use a config script that sets environment variables that get read by your packages
The last two aren’t bad options but they come with the overhead of having to carry around a dictionary of parameters that you can pass as **kwargs to a function or class. What if we could abstract that all away and get Python to do all the heavy lifting for us? Well, there’s where Gin comes in. Gin is a lightweight Python library developed by Google to specifically try and tackle this problem. In this article, we’re going to take a look at the basics of how to use the library and then try and work out if it’s actually something you would use.
Consider the following function:
def make_drink(drink_name:str, ingredients:list, ice:bool=False):    print(f"New drink called: {drink_name}")    print(f"Drink contains: " + ", ".join(ingredients))    if ice is True:        print("With ice")    else:        print("Without ice")
All we need to do to make this Gin configurable is to add a simple decorator to the function @gin.configurable which tells the library to index this function as one that can be configured in a .gin file - if you don’t know what a decorator I've linked an article below explaining how they work.
towardsdatascience.com
So let’s start writing that gin file:
make_drink.drink_name = "G&T"make_drink.ingredients = ["Gin", "Tonic", "Lemon"]make_drink.ice = False
The syntax is pretty simple, the first part is the function or class name, followed by a dot and then the argument name. This syntax support the assignment of all major Python variable types as you might expect, here we have a string, a list and a boolean but equally this could have been a dictionary, an integer or a tuple.
So now our function becomes:
import gingin.parse_config_file("path/to/gin_file.gin")@gin.configurabledef make_drink(drink_name:str, ingredients:list, ice:bool=False):    print(f"New drink called: {drink_name}")    print(f"Drink contains: " + ", ".join(ingredients))    if ice is True:        print("With ice")    else:        print("Without ice")
And we can call it like this:
>>> make_drink()New drink called: G&TDrink contains:Gin, Tonic, LemonWithout ice
If you’re anything like me this should annoy you. We didn’t define any default values for any of the arguments so, for anyone who’s reading the code, it isn’t clear where the values came from!
The developers of Gin saw this coming though and have provided a gin.REQUIRED value that can be used in the argument’s position to make it clear where the value should be sourced from. This comes with the added benefit of a custom error if you fail to provide that value in the Gin file.
>>> make_drink(gin.REQUIRED, gin.REQUIRED, ice=gin.REQUIRED)New drink called: G&TDrink contains:Gin, Tonic, LemonWithout ice
Now let’s build on our code and create a Drink class...
@gin.configurableclass Drink:    def __init__(self, name, ingredients, ice=True):        self.name = name        self.ingredients = ingredients        self.ice = ice@gin.configurabledef make_drink(drink: Drink):    print(f"New drink called: {drink.name}")    print(f"Drink contains: " + ", ".join(drink.ingredients))    if drink.ice is True:        print("With ice")    else:        print("Without ice")    print("\n")
Both the class and the function are configurable. Gin’s pretty cool so we can configure it such that make_drink takes an instance of Drink which itself takes parameter values:
make_drink.drink = @Drink()Drink.name = "Gin Martini"Drink.ingredients = ["Gin", "Vermouth"]Drink.ice = False
And call it...
>>> make_drink(gin.REQUIRED)New drink called: Gin MartiniDrink contains: Gin, VermouthWithout ice
But you know what’s better than one drink... two drinks... Let's consider the following example...
@gin.configurabledef make_two_drinks(drink1: Drink, drink2:Drink):    print("Making drink 1")    make_drink(drink1)            print("Making drink 2")    make_drink(drink2)
Both require an instance of Drink and you might try and write something like this.
make_two_drinks.drink1 = @Drink()Drink.name = "Gin Martini"Drink.ingredients = ["Gin", "Vermouth"]Drink.ice = Falsemake_two_drinks.drink2 = @Drink()Drink.name = "Gin Fizz"Drink.ingredients = ["Gin", "Sparkling Water"]Drink.ice = True
But this isn’t going to have the desired effect.
>>> make_two_drinks(gin.REQUIRED, gin.REQUIRED)Making drink 1New drink called: Gin FizzDrink contains: Gin, Sparkling WaterWith iceMaking drink 2New drink called: Gin FizzDrink contains: Gin, Sparkling WaterWith ice
We just get two Gin Fizzes! Fortunately for us, Gin has a concept of scopes that allow us to define different drinks for the two inputs.
make_two_drinks.drink1 = @drink1/Drink()drink1/Drink.name = "Gin Martini"drink1/Drink.ingredients = ["Gin", "Vermouth"]drink1/Drink.ice = Falsemake_two_drinks.drink2 = @drink2/Drink()drink2/Drink.name = "Gin Fizz"drink2/Drink.ingredients = ["Gin", "Sparkling Water"]drink2/Drink.ice = True
Which now yields exactly what we want.
>>> make_two_drinks(gin.REQUIRED, gin.REQUIRED)Making drink 1New drink called: Gin MartiniDrink contains: Gin, VermouthWithout iceMaking drink 2New drink called: Gin FizzDrink contains: Gin, Sparkling WaterWith ice
If a function calls a function and both have been configured in Gin, the uppermost in the tree will take precedence.
make_two_drinks.drink1 = @drink1/Drink()drink1/Drink.name = "Gin Martini"drink1/Drink.ingredients = ["Gin", "Vermouth"]drink1/Drink.ice = Falsemake_two_drinks.drink2 = @drink2/Drink()drink2/Drink.name = "Gin Fizz"drink2/Drink.ingredients = ["Gin", "Sparkling Water"]drink2/Drink.ice = Truemake_drink.drink = @Drink()Drink.name = "French 75"Drink.ingredients = ["Gin", "Champagne"]Drink.ice = False
Will give us...
>>> make_two_drinks(gin.REQUIRED, gin.REQUIRED)Making drink 1New drink called: Gin MartiniDrink contains: Gin, VermouthWithout iceMaking drink 2New drink called: Gin FizzDrink contains: Gin, Sparkling WaterWith ice
As we expect and...
>>> make_drink(gin.REQUIRED)New drink called: French 75Drink contains: Gin, ChampagneWithout ice
As we expect.
I’ve given you a flavour for the basic functionality here, Gin contains a whole load of other features such as macros and the ability to programmatically set and retrieve config parameters.
So that’s the basics, next we’ll take a look at how this applies to a real-world example. We’ll build a quick model and a training loop and show how Gin can be used to configure it.
import torch.nn as nnimport torch.nn.functional as F@gin.configurableclass DNN(torch.nn.Module):    def __init__(self, input_size, output_size, hidden_size):        super(DNN, self).__init__()                self.fc1 = nn.Linear(input_size, hidden_size)        self.fc2 = nn.Linear(hidden_size, hidden_size)        self.fc3 = nn.Linear(hidden_size, output_size)    def forward(self, x):        x = F.relu(self.fc1(x))        x = F.relu(self.fc2(x))        x = self.fc3(x)        return x@gin.configurabledef train(model: torch.nn.Module,           optimizer: torch.optim.Optimizer,           loss_func: torch.nn.modules.loss,          epochs: int,          train_dataloader: torch.utils.data.DataLoader,         ):    opt = optimizer(model.parameters())    for epoch in range(epochs):        for i, (inputs, labels) in enumerate(train_dataloader):            opt.zero_grad()            outputs = model(inputs)            loss = loss_func(outputs, labels)            loss.backward()            opt.step()    print('Finished Training')
We might build a Gin file like this...
train.epochs = 10train.model = @DNN()DNN.input_size = 10DNN.output_size = 10DNN.hidden_size = 128
Which is great but what about the optimizer and the loss function? Can we configure them too? Gin allows you to register external functions so that we can!
gin.external_configurable(torch.optim.Adam)gin.external_configurable(torch.nn.CrossEntropyLoss)
Gin has a lot of TensorFlow and PyTorch functions built-in so you don’t need to do this for everything, all you need to do is import gin.tensorflow and/or gin.torch. Our new Gin file looks like this...
train.epochs = 10train.model = @DNN()DNN.input_size = 10DNN.output_size = 10DNN.hidden_size = 128train.optimizer = @AdamAdam.lr = 0.001train.loss_func = @CrossEntropyLoss()
Notice the subtle difference between Adam and CrossEntropyLoss(), Adam needs to be instantiated with the model parameters which happens within the fit function whereasCrossEntropyLoss() is passed as an instantiated class.
The short answer is yes. I think it saves a lot of faff that comes with the task of configuring your project. I think the difficulty which comes with configuration, in general, is knowing that you can overdo it. The more you hide away in config files, the less readable your code tends to be. The Gin developers are also aware of this and say that we should “use Gin responsibly”. The other thing worth adding is there’s a bit of overhead in learning to us Gin and learning the best practices such that your code remains both Pythonic and readable. This is less true of learning to use TOML or YAML. Using these comes with its own learning curve but I just get the feeling there’s less that can go wrong.
I’ll caveat that opinion by adding: to write this article, I spent a few hours playing around with some of the core functionality of the library. In reality, I should probably form a proper opinion after using it in anger on a real-world project. But hey, you shouldn't trust my opinion anyway, try it out for yourself and let me know what you make of it.
GitHub repoGin’s walkthrough