Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Open In Colab   Open in Kaggle

Tutorial 3: Meta-learning

Week 2, Day 4: Macro-Learning

By Neuromatch Academy

Content creators: Hlib Solodzhuk, Ximeng Mao, Grace Lindsay

Content reviewers: Aakash Agrawal, Alish Dipani, Hossein Rezaei, Yousef Ghanbari, Mostafa Abdollahi, Hlib Solodzhuk, Ximeng Mao, Samuele Bolotta, Grace Lindsay, Alex Murphy

Production editors: Konstantine Tsafatinos, Ella Batty, Spiros Chavlis, Samuele Bolotta, Hlib Solodzhuk, Alex Murphy


Tutorial Objectives

Estimated timing of tutorial: 50 minutes

In this tutorial, you will examine how meta-learning separates the problem of continual learning into two stages.


Setup

Install and import feedback gadget

Source

Imports

Source

Figure settings

Source

Plotting functions

Source

Helper functions

Source

Data retrieval

Source

Set random seed

Source

Video 1: Meta-learning

Youtube
Bilibili

Section 1: Introducing meta-learning task

In this section, we introduce the meta-learning approach. We will discuss its main components and then focus on defining the task before proceeding to training.

The idea behind meta-learning is that we can learn to learn. Meta-learning is a phrase that is used to describe a lot of different approaches to making more flexible and adaptable systems. The MAML approach is essentially finding good initialization weights, and because those initial weights are learned based on the task set, the model is technically “learning to find a good place to learn from”. Learning to learn is a shorter, snappier description that can encompass many different meta-learning approaches.

Coding Exercise 1: Task space

We aim to develop a model that, in its desired state, can generalize knowledge about a particular set of similar tasks and adapt to a new task from this set extremely rapidly. Specifically, we aim to identify model weights such that the weights can be robustly and easily fine-tuned to create valid predictions on a specific task in just a few learning steps. This is similar to what humans excel at: performing one-shot tasks without much specific training.

For this, we first need to define the task space -- the set of tasks we want the model to learn. Formally, we consider a distribution over tasks p(τ)p(\tau) that we want our model to be able to adapt to. In the KK-shot learning setting, the model is trained to learn a new task τi\tau_{i} drawn from p(τ)p(\tau) using only KK samples drawn from τi\tau_{i}.

Our task space is parametrized by the tuple of parameters (A,B,ϕ,C)(A, B, \phi, C) describing the relationship between day and price as follows:

f(x)=Ax2+Bsin(πx+ϕ)+Cf(x) = A x^{2} + B sin(\pi x + \phi) + C

Please see Tutorial 1 if you are unsure about the role of these parameters as that is where they are described in detail. Thus, a particular task is a tuple of assigned values. For example, A=0.005A = 0.005, B=0.5B = 0.5, ϕ=0\phi = 0 and C=0C = 0.

You will implement FruitSupplyDataset, which enables the generation of a particular instance of the task. This is an extension (subclass) of torch.utils.data.Dataset to load data during training. PyTorch Dataset objects can be subclassed and its behavior customized as long as you define the initialiser, __len__ and __getitem__ methods.

Please complete the missing code lines to sample parameters uniformly from their min-max range, as well as to sample num_samples of days for inner and outer data. As we will see later during training, inner data is used to update task-specific weights and outer data is used to calculate base weight updates.

Now, let us visually inspect three distinct tasks from the defined task space. Explore even more of the tasks by changing the seed value in the slider!

Make sure you execute this cell to observe the plot!

Source
/opt/hostedtoolcache/Python/3.10.22/x64/lib/python3.10/site-packages/numpy/core/shape_base.py:65: FutureWarning: The input object of type 'Tensor' is an array-like implementing one of the corresponding protocols (`__array__`, `__array_interface__` or `__array_struct__`); but not a sequence (or 0-D). In the future, this object will be coerced as if it was first converted using `np.array(obj)`. To retain the old behaviour, you have to either modify the type 'Tensor', or assign to an empty array created with `np.empty(correct_shape, dtype=object)`.
  ary = asanyarray(ary)
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
File /opt/hostedtoolcache/Python/3.10.22/x64/lib/python3.10/site-packages/ipywidgets/widgets/interaction.py:240, in interactive.update(self, *args)
    238     value = widget.get_interact_value()
    239     self.kwargs[widget._kwarg] = value
--> 240 self.result = self.f(**self.kwargs)
    241 show_inline_matplotlib_plots()
    242 if self.auto_display and self.result is not None:

Cell In[10], line 9, in generate_tasks(seed)
      7 example_fruit_dataloader = DataLoader(example_fruit_dataset, batch_size=3)
      8 task_days, task_prices = next(iter(example_fruit_dataloader))
----> 9 plot_tasks(task_days, task_prices)

Cell In[4], line 16, in plot_tasks(task_days, task_prices)
     13 sorted_third_task_days, sorted_third_task_prices = zip(*sorted(zip(task_days[2], task_prices[2]), key=lambda pair: pair[0]))
     15 with plt.xkcd():
---> 16   plt.plot(sorted_first_task_days, sorted_first_task_prices, label = "First Task")
     17   plt.plot(sorted_second_task_days, sorted_second_task_prices, label = "Second Task")
     18   plt.plot(sorted_third_task_days, sorted_third_task_prices, label = "Third Task")

File /opt/hostedtoolcache/Python/3.10.22/x64/lib/python3.10/site-packages/matplotlib/pyplot.py:3590, in plot(scalex, scaley, data, *args, **kwargs)
   3582 @_copy_docstring_and_deprecators(Axes.plot)
   3583 def plot(
   3584     *args: float | ArrayLike | str,
   (...)
   3588     **kwargs,
   3589 ) -> list[Line2D]:
-> 3590     return gca().plot(
   3591         *args,
   3592         scalex=scalex,
   3593         scaley=scaley,
   3594         **({"data": data} if data is not None else {}),
   3595         **kwargs,
   3596     )

File /opt/hostedtoolcache/Python/3.10.22/x64/lib/python3.10/site-packages/matplotlib/axes/_axes.py:1724, in Axes.plot(self, scalex, scaley, data, *args, **kwargs)
   1481 """
   1482 Plot y versus x as lines and/or markers.
   1483 
   (...)
   1721 (``'green'``) or hex strings (``'#008000'``).
   1722 """
   1723 kwargs = cbook.normalize_kwargs(kwargs, mlines.Line2D)
-> 1724 lines = [*self._get_lines(self, *args, data=data, **kwargs)]
   1725 for line in lines:
   1726     self.add_line(line)

File /opt/hostedtoolcache/Python/3.10.22/x64/lib/python3.10/site-packages/matplotlib/axes/_base.py:303, in _process_plot_var_args.__call__(self, axes, data, *args, **kwargs)
    301     this += args[0],
    302     args = args[1:]
--> 303 yield from self._plot_args(
    304     axes, this, kwargs, ambiguous_fmt_datakey=ambiguous_fmt_datakey)

File /opt/hostedtoolcache/Python/3.10.22/x64/lib/python3.10/site-packages/matplotlib/axes/_base.py:488, in _process_plot_var_args._plot_args(self, axes, tup, kwargs, return_kwargs, ambiguous_fmt_datakey)
    485         kw[prop_name] = val
    487 if len(xy) == 2:
--> 488     x = _check_1d(xy[0])
    489     y = _check_1d(xy[1])
    490 else:

File /opt/hostedtoolcache/Python/3.10.22/x64/lib/python3.10/site-packages/matplotlib/cbook.py:1358, in _check_1d(x)
   1352 # plot requires `shape` and `ndim`.  If passed an
   1353 # object that doesn't provide them, then force to numpy array.
   1354 # Note this will strip unit information.
   1355 if (not hasattr(x, 'shape') or
   1356         not hasattr(x, 'ndim') or
   1357         len(x.shape) < 1):
-> 1358     return np.atleast_1d(x)
   1359 else:
   1360     return x

File <__array_function__ internals>:200, in atleast_1d(*args, **kwargs)

File /opt/hostedtoolcache/Python/3.10.22/x64/lib/python3.10/site-packages/numpy/core/shape_base.py:65, in atleast_1d(*arys)
     63 res = []
     64 for ary in arys:
---> 65     ary = asanyarray(ary)
     66     if ary.ndim == 0:
     67         result = ary.reshape(1)

ValueError: setting an array element with a sequence. The requested array has an inhomogeneous shape after 1 dimensions. The detected shape was (183,) + inhomogeneous part.
<Figure size 800x600 with 1 Axes>
Loading...

Coding Exercise 1 Discussion

  1. Do you think these particular tasks are similar? Do you expect the model to learn their general nature?


Section 2: Meta-training

Estimated timing to here from start of tutorial: 20 minutes

In this section, we are ready to train the model to generalize well across the tasks in this set. First, we will present the training procedure, discuss the intuition behind why it works, and then complete code snippets to do our first meta-learning training.

Coding Exercise 2: All about training

During meta-learning, a task τi\tau_{i} is sampled from p(τ)p(\tau). The model is trained with KK samples from this task, and the loss Lτi\mathcal{L}_{\tau_i} from τi\tau_{i} (which will be MSE for these tasks) is calculated, and then tested on new samples from τi\tau_{i}. The model is then improved by considering how the test error on new data from τi\tau_{i} changes with respect to its base parameters. The process can be described via the following pseudo-code:

[1] for new epoch do[2] Sample batch of tasks τi∼p(τ)[3] for all τi do[4] Sample K datapoints {x(j),y(j)} from τi[5] Evaluate ∇θLτi(fθ)[6] Compute adapted task-specific parameters with gradient descent:  θi′=θ−α∇θLτi(fθ)[7] Sample new datapoints {x(j),y(j)} from τi for the meta-update[8] end for[9] Update base parameters: θ←θ−β∇θ∑τi∼p(τ)Lτi(fθi′) using new sampled data and corresponding losses\begin{align*} & [1] \: \text{for new epoch do} \\ & [2] \: \quad\quad\text{Sample batch of tasks }\tau_i \sim p(\tau) \\ & [3] \: \quad\quad\text{for all }\tau_i \text{ do} \\ & [4] \: \quad\quad\quad\quad\text{Sample }K\text{ datapoints } \{x^{(j)}, y^{(j)}\}\text{ from }\tau_i \\ & [5] \: \quad\quad\quad\quad\text{Evaluate }\nabla_\theta \mathcal{L}_{\tau_i}(f_\theta) \\ & [6] \: \quad\quad\quad\quad\text{Compute adapted task-specific parameters with gradient descent:} \: \: \theta_i' = \theta - \alpha \nabla_\theta \mathcal{L}_{\tau_i}(f_\theta) \\ & [7] \: \quad\quad\quad\quad\text{Sample new datapoints }\{x^{(j)}, y^{(j)}\}\text{ from }\tau_i\text{ for the meta-update} \\ & [8] \: \quad\quad\text{end for} \\ & [9] \: \quad\quad\text{Update base parameters: }\theta \leftarrow \theta - \beta \nabla_\theta \sum_{\tau_i \sim p(\tau)} \mathcal{L}_{\tau_i}(f_{\theta_i'})\text{ using new sampled data and corresponding losses} \\ \end{align*}

At first, we sample a bunch of tasks for the new epoch (line [2][2]). Then, for each task (line [3][3]), we perform the same operations. Lines [4]−[6][4] - [6] correspond to the so-called “inner” loop (and, thus, sampled data in [4][4] is “inner”) - we calculate new task-specific parameters of the model for the particular instance of the task τi\tau_{i} by calculating the gradient with respect to the defined loss function (line [5][5]) and updating the task-specific component of the weights (line [6][6]). Then, we perform the “outer” loop and, thus, sample “outer” data from the very same tasks (as calculated for each τi\tau_{i} in line [7][7]) and then update the base parameters of the model based on the performances of the task-specific models (line [9][9], the loss is the sum of losses for all tasks in the epoch of training).

That’s meta-learning in a nutshell! Now, we are ready to complete the meta-learning model functions. Let us discuss their main components to quickly understand what is going on under the hood:

  • Inner_loop - Takes inner data (days and prices) from a particular task as input calculates inner loss, and performs parameter calculations manually with one gradient descent step.

  • Outer_loop - Takes outer data (days and prices) and calculates parameters for this particular task from inner_loop to calculate outer (meta) loss.

  • Train_tasks - Iterates through all tasks in the epoch, performs inner and outer loops, accumulates meta-loss from outer data, and optimizes the model’s base parameters.

The MetaLearningModel class inherits from UtilModel, which is defined at the very top of this notebook as it contains utility functions that are not of particular interest to us. For the outer loop, we use the Adam optimizer initialized on model.parameters() (the architecture of the model is the same as before—two hidden layers with 100 units each; it is defined right after this code snippet). The learning rate is defined in UtilModel as outer_learning_rate. Moreover, to calculate the loss for the outer loop based on the updated task-specific parameters, there is a custom manual_output function in UtilModel, which simply propagates through the architecture of the MLP for given weights. For the inner loop, there is inner_learning_rate for performing one gradient descent step.

Fill out the code below to calculate the loss and gradient updates for the inner loop.

In the function below, we define the model architecture, which will be passed as a parameter to the MetaLearningModel.

Now that the classes and functions are built, we are ready to look at the training procedure. We first define an architecture, meta-learning model, dataset, and dataloader. Then, we iterate through the dataloader and call the train_tasks function on the new batch of tasks. Every 1000 epochs, we average the meta-loss and output it. At the end of the training, we save the model parameters.

To achieve the required level of performance, set num_epochs in the cell below to 100,000, which would take the model at least one hour to train. Thus, at the start of the tutorial, we presented you with the already trained model SummerAutumnModel.pt. Ensure you can see it in the same folder as your tutorial notebook.

Below, we perform training for 1,000 epochs so that you can still run your first meta-learning experiment and verify the correctness of the implementation!

Training Progress:   0%|          | 0/1001 [00:00<?, ?it/s]
---------------------------------------------------------------------------
NotImplementedError                       Traceback (most recent call last)
Cell In[14], line 2
      1 set_seed(42)
----> 2 epoch_parameters = train("SummerAutumnModelTest", num_epochs = 1000, num_tasks = 30, num_samples = 40)

Cell In[13], line 26, in train(name, num_epochs, num_tasks, num_samples)
     21 pbar = tqdm(total=num_epochs + 1, desc="Training Progress")
     23 for epoch in range(num_epochs + 1):
     24 
     25     # Get new batch of tasks
---> 26     tasks = next(iter(fruit_dataloader))
     28     # Train meta model
     29     mean_batch_loss, model_parameters = meta_model.train_tasks(tasks)

File /opt/hostedtoolcache/Python/3.10.22/x64/lib/python3.10/site-packages/torch/utils/data/dataloader.py:630, in _BaseDataLoaderIter.__next__(self)
    627 if self._sampler_iter is None:
    628     # TODO(https://github.com/pytorch/pytorch/issues/76750)
    629     self._reset()  # type: ignore[call-arg]
--> 630 data = self._next_data()
    631 self._num_yielded += 1
    632 if self._dataset_kind == _DatasetKind.Iterable and \
    633         self._IterableDataset_len_called is not None and \
    634         self._num_yielded > self._IterableDataset_len_called:

File /opt/hostedtoolcache/Python/3.10.22/x64/lib/python3.10/site-packages/torch/utils/data/dataloader.py:674, in _SingleProcessDataLoaderIter._next_data(self)
    672 def _next_data(self):
    673     index = self._next_index()  # may raise StopIteration
--> 674     data = self._dataset_fetcher.fetch(index)  # may raise StopIteration
    675     if self._pin_memory:
    676         data = _utils.pin_memory.pin_memory(data, self._pin_memory_device)

File /opt/hostedtoolcache/Python/3.10.22/x64/lib/python3.10/site-packages/torch/utils/data/_utils/fetch.py:51, in _MapDatasetFetcher.fetch(self, possibly_batched_index)
     49         data = self.dataset.__getitems__(possibly_batched_index)
     50     else:
---> 51         data = [self.dataset[idx] for idx in possibly_batched_index]
     52 else:
     53     data = self.dataset[possibly_batched_index]

File /opt/hostedtoolcache/Python/3.10.22/x64/lib/python3.10/site-packages/torch/utils/data/_utils/fetch.py:51, in <listcomp>(.0)
     49         data = self.dataset.__getitems__(possibly_batched_index)
     50     else:
---> 51         data = [self.dataset[idx] for idx in possibly_batched_index]
     52 else:
     53     data = self.dataset[possibly_batched_index]

Cell In[9], line 41, in FruitSupplyDataset.__getitem__(self, idx)
     37 """Generate particular instance of task with prefined number of samples `num_samples`."""
     39 ###################################################################
     40 ## Fill out the following then remove
---> 41 raise NotImplementedError("Student exercise: complete parameters.")
     42 ###################################################################
     44 A = np.random.uniform(min_A, max_A, size = 1)

NotImplementedError: Student exercise: complete parameters.

You should be able to see that the meta-loss value is 0.123 for this case. Let us look at how the first layer weight values developed throughout the first 100 epochs. Here, we project this high-dimensional weight vector into a two-dimensional space using PCA. Play with the epoch value to see the (outer) evolution of the base weights (the left plot) and see how the (inner) task-specific weights change the total weight values in the given epoch (the right plot). For clarity of visualization, we use only the first ten tasks for each epoch in the inner weight visualizations.

Make sure you execute this cell to observe the plot!

Source
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[15], line 6
      3 number_of_components = 2
      5 #reshape parameters for PCA
----> 6 reshaped_parameters = epoch_parameters.reshape(epoch_parameters.shape[0] * epoch_parameters.shape[1], epoch_parameters.shape[2])
      8 #define PCA
      9 pca = PCA(n_components = number_of_components)

NameError: name 'epoch_parameters' is not defined

Think 2: Dreaming about a model

We suggest you use your imagination for a bit and visualize what you expect the base parameters of the model to have learned. When you load the pre-trained model, it will be in a state where it hasn’t updated its task-specific weights for a particular task. What would we expect its outputs to be in this state? Recall that the base parameters should somehow generalize the knowledge about the task family and still be robust for optimizing new tasks.

Take 3 minutes to draw predictions on your own, then discuss your pictures in a group. Then check it yourself right below this cell (you need to uncomment the relevant lines).

Discuss

Does the result match your drawings?

Section 3: Adapt to a particular task

Estimated timing to here from start of tutorial: 35 minutes

Coding Exercise 3: Fine-tuning model

Now, we would like to evaluate the learning ability of the meta-learned model by fine-tuning it to a particular task. For that, we clone (copy) the model parameters and perform a couple of gradient descent steps on a small amount of data for two specific tasks:

A=0.005,B=0.1,ϕ=0,C=1A = 0.005, B = 0.1, \phi = 0, C = 1
A=−0.005,B=0.1,ϕ=0,C=4A = -0.005, B = 0.1, \phi = 0, C = 4

Observe the second set of parameters. The model hasn’t been exposed to negative values of AA in any of the tasks during training. We will see whether it has still learned enough to generalize well!

We will test this by completing the finetune function, which takes the model, data (x_finetune, y_finetune) sampled from the particular task, and the number of gradient descent steps to take (finetune_gradient_steps). We will use the Stochastic Gradient Descent optimizer to perform these steps on the cloned base parameters so that we preserve the meta-learned ones.

The plot_performance function used below implements sampling and fine-tuning on sampled data under the hood as well as predicting for all days after that.

R-squared value is 0.96
<Figure size 800x600 with 1 Axes>
R-squared value is 0.91
<Figure size 800x600 with 1 Axes>

Interactive Demo 1: Number of data points & gradient steps

We’ve seen how the meta-learning procedure created a model that could quickly adapt to new tasks (including the inverted curve!). In the above examples, we used 30 samples from each task and 10 gradient steps. In this interactive demo, we invite you to explore the reasonable amount of data points to sample from a particular task and the number of gradient steps to make to fine-tune the model’s performance.

What is the relationship between these two fine-tuning parameters and performance? Have you noticed any lower bounds for any of the parameters such that below these thresholds, the performance can’t be expected to be great?

Make sure you execute this cell to observe the widget!

Source
R-squared value is 0.96
<Figure size 800x600 with 1 Axes>
R-squared value is 0.93
<Figure size 800x600 with 1 Axes>
Loading...

The observed interactive plots are summarized in the following 3D visualization, where the number of gradient steps and sample size are plotted against the R-squared score achieved on these particular values.

Make sure you execute this cell to observe the plot!

Source
---------------------------------------------------------------------------
NotImplementedError                       Traceback (most recent call last)
Cell In[21], line 7
      5 gradient_steps = np.arange(11)
      6 num_samples = np.arange(5, 155, 5)
----> 7 plot_sensitivity_r_squared("SummerAutumnModel", gradient_steps, num_samples)

Cell In[4], line 127, in plot_sensitivity_r_squared(name, list_gradient_steps, list_num_samples_finetune, fix_scale)
    125 for gradient_steps in list_gradient_steps:
    126     if gradient_steps:
--> 127         prediction = finetune(meta_model, torch.tensor(x_finetune).type(torch.float32),  torch.tensor(y_finetune).type(torch.float32), gradient_steps)(torch.tensor((np.expand_dims(days, 1) - days_mean) / days_std).type(torch.float32)).detach().numpy()
    128         legend_num_samples_finetune.append(num_samples_finetune)
    129         legend_gradient_steps.append(gradient_steps)

Cell In[17], line 20, in finetune(model, x_finetune, y_finetune, finetune_gradient_steps)
     16 optimizer = optim.SGD(clone.parameters(), lr = model.inner_learning_rate)
     18 ###################################################################
     19 ## Fill out the following then remove
---> 20 raise NotImplementedError("Student exercise: complete fine-tuning procedure.")
     21 ###################################################################
     23 for _ in range(finetune_gradient_steps):

NotImplementedError: Student exercise: complete fine-tuning procedure.

Notice that the z-axis (R-squared score) has a different scale for the plots (for the positive squared term constant, it starts with 0.84 while for the negative one, with -0.75). For a better comparison between these two cases, let us fix the scale as in a positive plot, but it means that we are going to lose some data points for the negative plot.

Make sure you execute this cell to observe the plot!

Source
---------------------------------------------------------------------------
NotImplementedError                       Traceback (most recent call last)
Cell In[22], line 7
      5 gradient_steps = np.arange(11)
      6 num_samples = np.arange(5, 155, 5)
----> 7 plot_sensitivity_r_squared("SummerAutumnModel", gradient_steps, num_samples, fix_scale = True)

Cell In[4], line 127, in plot_sensitivity_r_squared(name, list_gradient_steps, list_num_samples_finetune, fix_scale)
    125 for gradient_steps in list_gradient_steps:
    126     if gradient_steps:
--> 127         prediction = finetune(meta_model, torch.tensor(x_finetune).type(torch.float32),  torch.tensor(y_finetune).type(torch.float32), gradient_steps)(torch.tensor((np.expand_dims(days, 1) - days_mean) / days_std).type(torch.float32)).detach().numpy()
    128         legend_num_samples_finetune.append(num_samples_finetune)
    129         legend_gradient_steps.append(gradient_steps)

Cell In[17], line 20, in finetune(model, x_finetune, y_finetune, finetune_gradient_steps)
     16 optimizer = optim.SGD(clone.parameters(), lr = model.inner_learning_rate)
     18 ###################################################################
     19 ## Fill out the following then remove
---> 20 raise NotImplementedError("Student exercise: complete fine-tuning procedure.")
     21 ###################################################################
     23 for _ in range(finetune_gradient_steps):

NotImplementedError: Student exercise: complete fine-tuning procedure.

The Big Picture

Estimated timing of tutorial: 50 minutes

A summary of what we’ve learned:

  1. Meta-learning aims to create a model that can quickly learn any task from a distribution.

  2. To achieve this, it uses inner and outer learning loops to find base parameters from which a small number of gradient steps can lead to good performance on any task.

Please take some time to reflect on the inner workings of this algorithm and how this aims to solve the problems we’ve highlighted over the previous tutorials. What is it specifically about the distinction between task-specific and base weights that decouples the effects that previously might have led to catastrophic forgetting? There are numerous ways you can think about this, but we hope you use time in your tutorial groups to exchange ideas and compare your own thoughts. Developing these intuitions is a key skill we would like you to develop as budding NeuroAI researchers!