Skip to content

E . Usage examples

Unai Alegre-Ibarra edited this page Sep 15, 2022 · 27 revisions

The software is provided with some usage examples in the form of Jupiter notebooks. These can be found in brainspy-examples. Below, a general explanation of how to create a custom model is provided.

1. Processor

1.1 Loading and using a single surrogate model using Processor

If you have been handed a surrogate model file (training_data.pt) and you want to start making predictions you can do them following the code that is shown below. The training_data.pt file will contain the following information:

  • epoch: epoch at which the model was saved
  • model_state_dict: state dictionary of pytorch. Check for more information here.
  • info: Relevant information needed to load a surrogate model such as the neural network structure it had, relevant electrode information, as well as a copy of the configuration files with which the model data was gathered and the model trained.
  • train_losses: The loss values obtained on the training dataset at each epoch
  • val_losses: The loss values obtained on the validation dataset at each epoch
  • min_val_loss: Minimum loss obtained by the model on the validation dataset
    import torch

    import matplotlib.pyplot as plt

    from brainspy.processors.processor import Processor

    from brainspy.utils.pytorch import TorchUtils as TU
    from brainspy.utils.io import load_configs
    from brainspy.utils.transforms import linear_transform

    model_data = torch.load('training_data.pt', map_location=TU.get_device(
    ))  # Load the data from the model into the right device (CPU or GPU)

    configs = load_configs('sw_processor_configs.yaml')

    p = Processor(configs,
                  model_state_dict=model_data['model_state_dict'],
                  info=model_data['info'])

    p = TU.format(p) # Send the model to CPU or GPU (Same place as where the state dictionary is)

    random_data = torch.rand(256,7,device=TU.get_device()) # 256 data points with 7 inputs, Send the model to CPU or GPU (Same place as where the state dictionary and the model are)


    # (OPTIONAL) Linearly transform data to the adequate ranges
    min_ranges = p.get_voltage_ranges()[:, 0]
    max_ranges = p.get_voltage_ranges()[:, 1]

    transformed_random_data = linear_transform(min_ranges, max_ranges,
                                               random_data.min(),
                                               random_data.max(), random_data)

    # Read predictions from model

    predictions = p(transformed_random_data)

    plt.plot(predictions.detach().cpu())
    plt.show()

Below, an example of YAML configs that could be used for the processor (sw_processor_configs.yaml):

processor_type: 'simulation'
waveform:
  plateau_length: 30
  slope_length: 30

1.2 Validating a Processor in hardware

Following the usage example below, the same data can be measured on the software simulation than on hardware, as follows:


import torch

from brainspy.processors.processor import Processor
from brainspy.utils.io import load_configs
from brainspy.utils.pytorch import TorchUtils as TU
from bspysmg.utils.inputs import generate_sawtooth_simple

model_data = torch.load('C:/Users/Unai/Documents/programming/smg_basic/tmp/training_data.pt',map_location=TU.get_device())
sw_configs = load_configs('sw_processor_configs.yaml')
hw_configs = load_configs('hw_processor_configs.yaml')

# The data below creates a sawtooth from zero to a minimum point, to a maximum point and then back to zero again

idx = 5
size = 2000

min_ranges = p.get_voltage_ranges()[:, 0]
max_ranges = p.get_voltage_ranges()[:, 1]

data = torch.zeros(size,7)
data[:,idx] = TU.format(generate_sawtooth_simple(min_ranges[idx].item(), max_ranges[idx].item(), size))

########## 

# The software processor is declared
p = Processor(sw_configs, model_state_dict=model_data['model_state_dict'], info=model_data['info'])

sw_prediction = p(data) # Prediction is made with the surrogate model

# The software processor is changed to hardware
p.swap(hw_configs, info=model_data['info'])

hw_prediction = p(data) # Prediction is made with hardware (using the same data)
p.close() # After finishing sending data to the processor, it has to be closed

Below, an example of YAML configs that could be used for this exercise (hw_processor_configs.yaml):

processor_type: 'cdaq_to_cdaq'
waveform:
  plateau_length: 30
  slope_length: 30

driver:

    amplification: [39.5] # Amplification factor of Amplifier;
    # Maximum/Minimum measured current: +158/-158 nA
    # Maximum/Minimum Op Amp. output voltage: +4/-4 V
    # Amplification equation: abs(Maximum measured current)/abs(Maximum Op. Amp output voltage)
    # Amplification: 158/4 = 39.5
    # Note that amplification value should be re-calculated for each setup seperately.

    inverted_output: True # If the amplifier circuitry is inverting amplifier

    instruments_setup: 
      multiple_devices: False # If True, device names (e.g., A: ) should be specified
      # If false, device names should be removed.

      trigger_source: cDAQ2
      activation_sampling_frequency: 5000 # Digital-to-Analogue Converter module update rate
      readout_sampling_frequency: 10000 # Analogue-to-Digital Converter sampling rate
      average_io_point_difference: True # Averaging mode of the ADC output;
      #If true, the oversampled points will be averaged,
      #If false, the oversampled points will be presented as they are.
      activation_instrument: cDAQ2Mod2
      activation_channels: [6, 0, 7, 5, 2, 4, 3] # Analogue output channels of DAC
      activation_voltage_ranges:
        [[-0.5500,  0.3250],
        [-0.9500,  0.5500],
        [-1.0000,  0.6000],
        [-1.0000,  0.6000],
        [-1.0000,  0.6000],
        [-0.9500,  0.5500],
        [-0.5500,  0.3250]
        ]
      activation_channel_mask: [1, 1, 1, 1, 1, 1, 1]
      readout_instrument: cDAQ2Mod8
      readout_channels: [0] # Analogue input channel(s) of ADC

2. DNPU

The main difference between the processor and the DNPU is that the DNPU has only data input electrodes, and it handles internally the control voltage electrodes, as opposed to having only activation electrodes. The DNPU also stores, the control voltage electrodes. When saving a DNPU containing a solution for a particular task during simulations, it can also be reproduced and validated against its hardware component. The procedure is very similar to doing it only with a Processor, and the same configs shown in the previous examples can be used:


import torch

from brainspy.processors.processor import Processor
from brainspy.processors.dnpu import DNPU
from brainspy.utils.io import load_configs
from brainspy.utils.pytorch import TorchUtils as TU
from bspysmg.utils.inputs import generate_sawtooth_simple


sw_configs = load_configs('sw_processor_configs.yaml')
hw_configs = load_configs('hw_processor_configs.yaml')

model_data = torch.load('training_data.pt',map_location=TU.get_device()) # Weights and info dictionary from the processor
dnpu_state_dict = torch.load('dnpu_state_dict.pt',map_location=TU.get_device()) # Weights of the control voltages

p = Processor(configs, info=model_data['info']) # Load the processor with random weights
dnpu = DNPU(p, data_input_indices=[[1,2]]) # Load an instance of a DNPU

dnpu.load_state_dict(state_dict=dnpu_state_dict) # Load the state dictionary for the DNPU

# The data below creates a sawtooth from zero to a minimum point, to a maximum point and then back to zero again
idx = 1
size = 2000

min_ranges = p.get_voltage_ranges()[:, 0]
max_ranges = p.get_voltage_ranges()[:, 1]

data = torch.zeros(size,2)
data[:,idx] = TU.format(generate_sawtooth_simple(min_ranges[idx].item(), max_ranges[idx].item(), size))
##############

sw_prediction = dnpu(data) # Make a prediction on the software simulated DNPU

dnpu.hw_eval(hw_configs, data_input_indices=[[1,2]] ) # Put the DNPU in hardware evaluation mode

hw_prediction = dnpu(data) # Make a prediction using hardware, and the same input data

p.close()

3. Custom Models

3.1 Creating a custom model with a single DNPU layer

The main intention for this library is to be used as an extension of PyTorch, where you can create your custom dopant-network based circuit designs to simulate them and find adequate control voltages for a particular task. Then, the library will support testing if this behaviour matches with real hardware dopant-network devices. Custom models are expected to be an instance of torch.nn.Module. An example of a custom model with a single DNPU is presented below. Note that, this example is for a single DNPU module, and that if more were required, some of the functions that need to be implemented change.


from brainspy.processors.dnpu import DNPU
from brainspy.processors.processor import Processor
from brainspy.utils.pytorch import TorchUtils


class SingleDNPUCustomModel(torch.nn.Module):
    def __init__(self, configs):
        super(SingleDNPUCustomModel, self).__init__()
        self.gamma = 1
        self.node_no = 1
        model_data = torch.load(configs['model_dir'],
                                map_location=TorchUtils.get_device())
        processor = Processor(configs, model_data['info'],
                              model_data['model_state_dict'])
        self.dnpu = DNPU(processor=processor,
                         data_input_indices=[[2,3]] *
                         self.node_no,
                         forward_pass_type='vec')
        # Remember to add an input transformation, if required   
        # In this case, the example assumes that input data will be in a range from 0 to 1
        self.dnpu.add_input_transform([0, 1])

    def forward(self, x):
        x = self.dnpu(x)
        return x

    # If you want to swap from simulation to hardware, or vice-versa you need these functions
    def hw_eval(self, configs, info=None):
        self.eval()
        self.dnpu.hw_eval(configs, info)

    def sw_train(self, configs, info=None, model_state_dict=None):
        self.train()
        self.dnpu.sw_train(configs, info, model_state_dict)

    ##########################################################################################

    # If you want to be able to get information about the ranges from outside, you have to add the following functions.
    def get_input_ranges(self):
        return self.dnpu.get_input_ranges()

    def get_control_ranges(self):
        return self.dnpu.get_control_ranges()

    def get_control_voltages(self):
        return self.dnpu.get_control_voltages()

    def set_control_voltages(self, control_voltages):
        self.dnpu.set_control_voltages(control_voltages)

    def get_clipping_value(self):
        return self.dnpu.get_clipping_value()

    # For being able to maintain control voltages within ranges, you should implement the following functions (only those which you are planning to use)
    def regularizer(self):
        return self.gamma * (self.dnpu.regularizer())

    def constraint_weights(self):
        self.dnpu.constraint_control_voltages()

    # For being able to produce same target size as outputs when using hardware validation
    def format_targets(self, x: torch.Tensor) -> torch.Tensor:
        return self.dnpu.format_targets(x)

    ######################################################################################################################################################

    # If you want to implement on-chip GA, you need these functions
    def is_hardware(self):
        return self.dnpu.processor.is_hardware

    def close(self):
        self.dnpu.close()

3.2 Creating a custom model with multiple DNPU layers

import torch

from brainspy.processors.dnpu import DNPU
from brainspy.processors.modules.bn import DNPUBatchNorm
from brainspy.processors.processor import Processor
from brainspy.utils.pytorch import TorchUtils

    def __init__(self, configs):
        super(MultipleDNPUCustomModel, self).__init__()
        self.gamma = 1
        self.node_no_l1 = 2
        self.node_no_l2 = 1
        model_data = torch.load(configs['model_dir'],
                                map_location=TorchUtils.get_device())
        processor = Processor(configs, model_data['info'],
                              model_data['model_state_dict'])
        self.dnpu1 = DNPUBatchNorm(processor=processor,
                                   data_input_indices=[[2, 3]] *
                                   self.node_no_l1,
                                   forward_pass_type='vec')
        self.dnpu2 = DNPU(processor=processor,
                          data_input_indices=[[2, 3]] * self.node_no_l2,
                          forward_pass_type='vec')
        # Remember to add an input transformation, if required
        # In this case, the example assumes that input data will be in a range from 0 to 1
        self.dnpu1.add_input_transform([-1, 1])
        self.dnpu2.add_input_transform([0, 1])

    def forward(self, x):
        x = torch.cat((x, x), dim=1)
        x = torch.sigmoid(self.dnpu1(x))
        x = self.dnpu2(x)
        return x

    # If you want to swap from simulation to hardware, or vice-versa you need these functions
    def hw_eval(self, configs, info=None):
        self.eval()
        self.dnpu1.hw_eval(configs, info)
        self.dnpu2.hw_eval(configs, info)

    def sw_train(self, configs, info=None, model_state_dict=None):
        self.train()
        self.dnpu1.sw_train(configs, info, model_state_dict)
        self.dnpu2.sw_train(configs, info, model_state_dict)

    ##########################################################################################

    # If you want to be able to get information about the ranges from outside, you have to add the following functions.
    def get_input_ranges(self):
        return torch.cat(
            (self.dnpu1.get_input_ranges(), self.dnpu2.get_input_ranges()))

    def get_control_ranges(self):
        return torch.cat(
            (self.dnpu1.get_control_ranges(), self.dnpu2.get_control_ranges()))

    def get_control_voltages(self):
        return torch.cat((self.dnpu1.get_control_voltages(),
                          self.dnpu2.get_control_voltages()))

    def set_control_voltages(self, control_voltages):
        self.dnpu1.set_control_voltages(control_voltages[:2])
        self.dnpu2.set_control_voltages(control_voltages[-1].unsqueeze(0))

    def get_clipping_value(self):
        return self.dnpu.get_clipping_value()

    # For being able to maintain control voltages within ranges, you should implement the following functions (only those which you are planning to use)
    def regularizer(self):
        return self.gamma * (self.dnpu1.regularizer() +
                             self.dnpu2.regularizer())

    def constraint_weights(self):
        self.dnpu1.constraint_control_voltages()
        self.dnpu2.constraint_control_voltages()

    # For being able to produce same target size as outputs when using hardware validation
    def format_targets(self, x: torch.Tensor) -> torch.Tensor:
        return self.dnpu2.format_targets(self.dnpu1.format_targets(x))

    ######################################################################################################################################################

    # If you want to implement on-chip GA, you need these functions
    def is_hardware(self):
        return self.dnpu1.processor.is_hardware(
        ) or self.dnpu2.processor.is_hardware()

    def close(self):
        self.dnpu1.close()
        self.dnpu2.close()

Other usage examples

This section covers a very simple description of usage cases. For other usage cases related to the surrogate model generation, have a look at the usage examples of the brainspy-smg Wiki. For more advanced examples, have a look at the Jupyter notebooks on brainspy-tasks.