Intro to EIANN#
Note
🚧 Work in Progress: This documentation site is currently under construction. Content may change frequently.
EIANN is a PyTorch-based library for building neural networks.
At its core, the syntax for building these networks uses python dicts to specify the network architecture and parameters. We provide two easy ways to generate these dicts: YAML files or a programmatic interface with a NetworkBuilder class. The first method is convenient for quickly prototyping and testing different network architectures. The second method is particularly useful for hyperparameter optimization, systematic architecture search, and experiment reproducibility. Here we will show you examples of how to do both.
Since thie library is based on PyTorch, a basic familiarity with PyTorch objects and syntax is helpful, but not required, to use EIANN. You can geet a quick overview of PyTorch by going through the PyTorch Quickstart Tutorial.
Basic building blocks#
To build a neural network using the EIANN library, you need to specify 3 things:
The number neural populations within in each “layer” and how many neurons are in each population
The projections (how the populations connect to each other)
Global parameters that we want to apply to the whole network (e.g. learning rate)
This population-centric approach is flexible and allows you to recurrently connect any two populations, regardless of layer hierarchy.
Each of these things will be defined through 3 separate python dicts. EIANN will then build a PyTorch network the specified architecture.
1. Programming interface#
import EIANN.EIANN as eiann
network_config = eiann.NetworkBuilder()
# Define layers and populations
network_config.layer('Input').population('E', size=784)
network_config.layer('H1').population('E', size=500, activation='relu')
network_config.layer('Output').population('E', size=10, activation='softmax')
# Create connections between populations
network_config.connect(source='Input.E', target='H1.E')
network_config.connect(source='H1.E', target='Output.E')
# Build the network
network_config.print_architecture()
==================================================
Network Architecture:
Input.E (784) -> H1.E (500): No learning rule
H1.E (500) -> Output.E (10): No learning rule
==================================================
Rather than defining only the weight matrix dimensions, this population-based syntax allows you to also specify attributes at the population level. For example, if you want the network to respect Dale’s Law, you can specify whether each population is excitatory or inhibitory. Doing this will automatically bound the sign of outgoing weights, both at initialization and during training.
network_config.layer('H1').population('E', 500, 'relu').type('Exc')
network_config.layer('H1').population('SomaI', 50, 'relu').type('Inh')
network_config.print_architecture()
==================================================
Network Architecture:
Input.E (784) -> H1.E (500): No learning rule
H1.E (500) -> Output.E (10) [Exc]: No learning rule
Unconnected populations:
H1.SomaI (50) [Inh]
==================================================
Should you prefer, these weight bounds can also be set or overwritten for individual projections:
network_config.connect(source='H1.E', target='H1.SomaI').type('Exc')
network_config.connect(source='H1.SomaI', target='H1.E').type('Inh')
network_config.print_architecture()
==================================================
Network Architecture:
Input.E (784) -> H1.E (500): No learning rule
H1.SomaI (50) -> H1.E (500) [Inh]: No learning rule
H1.E (500) -> H1.SomaI (50) [Exc]: No learning rule
H1.E (500) -> Output.E (10) [Exc]: No learning rule
==================================================
So far we have not specified the type of learning rule we want to use. If we want to use a single learning rule (e.g. Backprop) throughout the whole network, this can be done globally:
# Set Backprop as the learning rule for the whole network
network_config.set_learning_rule('Backprop')
network_config.print_architecture()
==================================================
Network Architecture:
Input.E (784) -> H1.E (500): Backprop
H1.SomaI (50) -> H1.E (500) [Inh]: Backprop
H1.E (500) -> H1.SomaI (50) [Exc]: Backprop
H1.E (500) -> Output.E (10) [Exc]: Backprop
==================================================
If we instead want a different learning rule for a subset of the connections, we can also manually specify it for a given layer, population, or projection
network_config.connect(source='H1.SomaI', target='H1.E').type('Inh').learning_rule('BCM')
network_config.print_architecture()
==================================================
Network Architecture:
Input.E (784) -> H1.E (500): Backprop
H1.SomaI (50) -> H1.E (500) [Inh]: BCM
H1.E (500) -> H1.SomaI (50) [Exc]: Backprop
H1.E (500) -> Output.E (10) [Exc]: Backprop
==================================================
network_config.set_learning_rule_for_population(target_layer='H1', target_population='E', rule='Hebbian', learning_rate=0.01)
network_config.print_architecture()
==================================================
Network Architecture:
Input.E (784) -> H1.E (500): Hebbian (lr=0.01)
H1.SomaI (50) -> H1.E (500) [Inh]: Hebbian (lr=0.01)
H1.E (500) -> H1.SomaI (50) [Exc]: Backprop
H1.E (500) -> Output.E (10) [Exc]: Backprop
==================================================
network_config.set_learning_rule_for_layer(target_layer='H1', rule='Ojas_rule', learning_rate=0.01)
network_config.print_architecture()
==================================================
Network Architecture:
Input.E (784) -> H1.E (500): Ojas_rule (lr=0.01)
H1.SomaI (50) -> H1.E (500) [Inh]: Ojas_rule (lr=0.01)
H1.E (500) -> H1.SomaI (50) [Exc]: Ojas_rule (lr=0.01)
H1.E (500) -> Output.E (10) [Exc]: Backprop
==================================================
Note
You need to make sure that the learning rule you want to use actually exists in the EIANN.rules
module. In later tutorials, we will show you how to write your own learning rule.
Once we have specified all the populations and projections that define the network architecture, we can use the build_network
function to create the neural network (pytorch) object.
network_random_seed = 42 # Specifying a seed for the random number generator is optional, but enables your experiments to be reproducible.
network = network_config.build(seed=network_random_seed)
Network(
(criterion): MSELoss()
(module_dict): ModuleDict(
(H1E_InputE): Projection(in_features=784, out_features=500, bias=False)
(H1E_H1SomaI): Projection(in_features=50, out_features=500, bias=False)
(H1SomaI_H1E): Projection(in_features=500, out_features=50, bias=False)
(OutputE_H1E): Projection(in_features=500, out_features=10, bias=False)
)
(parameter_dict): ParameterDict(
(H1E_bias): Parameter containing: [torch.FloatTensor of size 500]
(H1SomaI_bias): Parameter containing: [torch.FloatTensor of size 50]
(OutputE_bias): Parameter containing: [torch.FloatTensor of size 10]
)
)
2. YAML interface#
When running many experiments, optimizing architectures or parameters, and comparing results, it can be helpful to separate model specifications from your code. One way to do this is by using a YAML file to describe the model. Then, when you run the model, you only need to specify the path or filename of the YAML file.
If you’re not familiar with YAML, it’s just a structured text format that is both human-readable and easy for code to parse.
Here’s an example of a YAML file describing a simple EIANN model:
layer_config:
Input:
E:
size: 784
H1:
E:
size: 500
activation: relu
Output:
E:
size: 10
activation: relu
projection_config:
H1.E:
Input.E:
learning_rule: Backprop
Output.E:
H1.E:
learning_rule: Backprop
training_kwargs:
learning_rate: 0.025
So instead of specifying the parameters of the model in your code, you can simply load this YAML file:
import EIANN.utils as ut
config_file_path = f"../EIANN/network_config/MNIST_templates/example_EIANN_config_1.yaml"
network_random_seed = 42 # Specifying a seed for the random number generator is optional, but enables your experiments to be reproducible.
network = ut.build_EIANN_from_config(config_file_path, network_seed=network_random_seed)
print(network)
Network(
(criterion): MSELoss()
(module_dict): ModuleDict(
(H1E_InputE): Projection(in_features=784, out_features=500, bias=False)
(OutputE_H1E): Projection(in_features=500, out_features=10, bias=False)
)
(parameter_dict): ParameterDict(
(H1E_bias): Parameter containing: [torch.FloatTensor of size 500]
(OutputE_bias): Parameter containing: [torch.FloatTensor of size 10]
)
)
For more complex models, the YAML file also allows you to specify a number of optional parameters for each population and projection:
(For a full list of parameters, see the glossary.)
layer_config:
Input:
E:
size: 784
H1:
E:
size: 500
activation: softplus
activation_kwargs:
beta: 20.0
Output:
E:
size: 10
activation: softmax
projection_config:
H1.E:
Input.E:
weight_init: half_kaiming
type: exc
weight_init_args: [2.4]
direction: F
learning_rule: Backprop
learning_rule_kwargs:
learning_rate: 0.2
H1.SomaI:
weight_init: half_kaiming
type: inh
weight_init_args: [1.1]
direction: R
learning_rule: Backprop
H1.SomaI:
H1.E:
weight_init: half_kaiming
type: exc
weight_init_args: [1.]
direction: F
learning_rule: None
Output.E:
H1.E:
learning_rule: Backprop
training_kwargs:
tau: 3
forward_steps: 15
backward_steps: 3
learning_rate: 0.05
In the next tutorial, we will see how to train a simple feedforward neural network to classify MNIST handwritten digits