//  [python]
def make_munu(self, *args, **kwargs):

        # *** targets ***
        energies, forces = [], []
        atom_types = set()
        # for dummy atom type Z
        atom_counts = torch.zeros(len(self.data), 120)
        for i, atoms in enumerate(self.data):
            for z, c in atoms.counts().items():
                atom_types.add(z)
                atom_counts[i, z] = c
            energies.append(atoms.target_energy.view(-1))
            forces.append(atoms.target_forces.view(-1))
        energies = torch.cat(energies)
        forces = torch.cat(forces)
        targets = torch.cat([energies, forces])
        size = targets.numel()
        atom_types = sorted(atom_types)

        # *** kernel matrices ***
        # main block:
        kern_1 = torch.cat([self.Ke, self.Kf])

        # Constant-energy-shift block:
        ntypes = len(atom_types)
        ke_shift = atom_counts[:, atom_types]
        kf_shift = torch.zeros(self.Kf.size(0), ntypes)
        kern_2 = torch.cat([ke_shift, kf_shift])

        # patch all blocks:
        kern = torch.cat([kern_1, kern_2], dim=1)

        # *** legacy stuff ***
        sigma = 0.01
        self.scaled_noise = {"all": sigma}
        chol = torch.linalg.cholesky(self.M)
        self.ridge = torch.tensor(0.0)
        self.choli = chol.inverse().contiguous()
        self.Mi = self.choli.t() @ self.choli
        chol_size = chol.size(0)
        self.mean._weight = {}
        self.mean.weights = {}

        # *** sgpr extensions ***
        sgpr = True
        if sgpr:
            _kern_1 = sigma * chol.t()
            _kern_2 = torch.zeros(chol_size, ntypes)
            _kern = torch.cat([_kern_1, _kern_2], dim=1)
            kern = torch.cat([kern, _kern])
            _targets = torch.zeros(self.tasks * chol_size)
            targets = torch.cat([targets, _targets])

        # *** tasks kernel ***
        # covariance between tasks:
        # in principle this should be optimized as a hyper parameter.
        # for now we set it equal to correlation of forces in different
        # tasks.

        """
        As the optimization of the intertask correlations is time-consuming,
        an alternative is to (acvtively) optimize the intertask correlations over the course of the simulation.
        """
        if self.tasks_kern_optimization is True:
            # self.tasks_kern_L = torch.eye(self.tasks)
            # self.tasks_kern_L += 1e-2

            # decoupled optimizer for mu and W
            # 1. Initial weights \mu optimization ***
            design = torch.kron(kern, self.tasks_kern)
            solution, predictions = least_squares(design, targets, solver=self.algo)
            self.multi_mu = solution
            self.multi_types = {z: i for i, z in enumerate(atom_types)}

            for i, _ in enumerate(range(self.niter_tasks)):
                # 2. Inter-task kernel W=L@L.T optimization ***
                x1 = self.tasks_kern_L[0][0].item()
                x2 = self.tasks_kern_L[1][0].item()
                x3 = self.tasks_kern_L[1][1].item()
                res = minimize(
                    optimize_task_kern_twobytwo,
                    [x1, x2, x3],
                    args=(kern, solution, targets),
                )
                self.tasks_kern_L[0][0] = res.x[0]
                self.tasks_kern_L[1][0] = res.x[1]
                self.tasks_kern_L[1][1] = res.x[2]
                self.tasks_kern = self.tasks_kern_L @ self.tasks_kern_L.T

                # 3. Re-optimization of weights \mu based on an updated W ***
                design = torch.kron(kern, self.tasks_kern)
                solution, predictions = least_squares(design, targets, solver=self.algo)
                self.multi_mu = solution
                self.multi_types = {z: i for i, z in enumerate(atom_types)}
                # print(f'{i} Optimized tasks corrs: {self.tasks_kern}, Lower: {self.tasks_kern_L}, error: {res.fun}')
        else:
            # for predetermined tasks corr
            # self.tasks_kern = tasks_correlation(forces.view(self.tasks,-1),corr_coef='pearson')
            self.tasks_kern = torch.eye(self.tasks)

            # for independent tasks, set:
            # self.tasks_kern = torch.eye(self.tasks)
            # a small ridge maybe needed in case tasks are identical:
            # self.tasks_kern += torch.eye(self.tasks) * 1e-1

            # np corr
            # import numpy as np
            # self.tasks_kern = np.corrcoef(forces.view(-1, self.tasks)[:,0], forces.view(-1, self.tasks)[:,1])
            # self.tasks_kern = torch.from_numpy(self.tasks_kern)

            # *** solution ***
            design = torch.kron(kern, self.tasks_kern)
            solution, predictions = least_squares(design, targets, solver=self.algo)
            self.multi_mu = solution
            self.multi_types = {z: i for i, z in enumerate(atom_types)}

        # *** stats ***
        # TODO: per-task vscales?
        split = chol_size * self.tasks
        self.mu = solution[:split].reshape(-1, self.tasks)
        self.make_stats((targets[:size], predictions[:size]))