Skip to content

landscape

This module provides functions to compute the loss landscape of a model and visualize it in various ways.

It includes methods for computing the loss landscape, loading it from a file, and visualizing it as a 3D surface, contour plot, or persistence barcode.

LossLandscape

A class representing a loss landscape of a model.

It contains methods to compute the landscape, visualize it, and analyze its topological properties.

Source code in src/landscaper/landscape.py
class LossLandscape:
    """A class representing a loss landscape of a model.

    It contains methods to compute the landscape, visualize it, and analyze its topological properties.
    """

    @staticmethod
    def compute(*args, **kwargs) -> "LossLandscape":
        """Computes a loss landscape and directly creates a LossLandscape object.

        See `landscaper.compute` for more information.

        Returns:
            (LossLandscape) A LossLandscape object.
        """
        loss, coords = compute_loss_landscape(*args, **kwargs)
        return LossLandscape(loss, coords)

    @staticmethod
    def load_from_npz(fp: str) -> "LossLandscape":
        """Creates a LossLandscape object directly from an `.npz` file.

        Args:
            fp (str): path to the file.

        Returns:
            (LossLandscape) A LossLandscape object created from the file.
        """
        loss, coords = load_landscape(fp)
        return LossLandscape(loss, coords)

    def __init__(self, loss: npt.ArrayLike, ranges: npt.ArrayLike) -> None:
        """Initializes a LossLandscape object.

        Args:
            loss (npt.ArrayLike): A numpy array representing the loss values of the landscape.
            ranges (npt.ArrayLike): A list of numpy arrays representing the ranges of each dimension of the landscape.

        Raises:
            ValueError: If the dimensions of the loss array do not match the number of coordinates.
        """
        self.loss = loss
        # converts meshgrid output of arbitrary dimensions into list of coordinates
        grid = np.meshgrid(*ranges)
        self.coords = np.array([list(z) for z in zip(*(x.flat for x in grid), strict=False)])

        if self.coords.shape[0] != np.multiply.reduce(self.loss.shape):
            raise ValueError(
                f"Loss dimensions do not match coordinate dimensions: Loss - {self.loss.shape}; "
                f"Coordinates - {self.coords.shape}"
            )

        self.ranges = ranges
        self.dims = self.coords.shape[1]
        self.ms_complex = None
        self.sub_tree = None
        self.super_tree = None
        self.contour_tree = None
        self.topological_indices = None
        self.loss_range = np.max(loss) - np.min(loss)

    def save(self, filename: str) -> None:
        """Saves the loss and coordinates of the landscape to the specified path for later use.

        Args:
            filename (str): path to save the landscape to.
        """
        np.savez(filename, loss=self.loss, coordinates=self.ranges)

    def get_sublevel_tree(self) -> tp.MergeTree:
        """Gets the merge tree corresponding to the minima of the loss landscape.

        Returns:
            A tp.MergeTree object corresponding to the minima of the loss landscape.
        """
        if self.sub_tree is None:
            self.sub_tree = merge_tree(self.loss, self.coords)
        return self.sub_tree

    def get_super_tree(self) -> tp.MergeTree:
        """Gets the merge tree corresponding to the maxima of the loss landscape.

        Returns:
            A tp.MergeTree object corresponding to the maxima of the loss landscape.
        """
        if self.super_tree is None:
            self.super_tree = merge_tree(self.loss, self.coords, direction=-1)
        return self.super_tree

    def get_contour_tree(self, **kwargs) -> PContourTree:
        if self.contour_tree is None:
            ct = PContourTree(graph=ngl.EmptyRegionGraph(beta=1.0, relaxed=False, p=2.0), **kwargs)
            ct.build(np.array(self.coords), self.loss.flatten())
            ct.vals = [x for x in ct.sortedNodes if x[0] in ct.superNodes]
            ct.nodes = {n: dict(ct.vals)[n] for n in ct.superNodes}
            # TODO: figure out how to draw a contour tree profile
            self.contour_tree = ct
        return self.contour_tree

    def get_ms_complex(self) -> tp.MorseSmaleComplex:
        """Gets the MorseSmaleComplex corresponding to the loss landscape.

        Returns:
            A tp.MorseSmaleComplex.
        """
        if self.ms_complex is None:
            ms_complex = tp.MorseSmaleComplex(
                graph=ngl.EmptyRegionGraph(beta=1.0, relaxed=False, p=2.0),
                gradient="steepest",
            )
            ms_complex.build(np.array(self.coords), self.loss.flatten())
            self.ms_complex = ms_complex
        return self.ms_complex

    def get_topological_indices(self, mt) -> dict[int, int]:
        """Returns a dictionary that maps point indices to their topological indices.

        Returns:
            (dict[int, int]): A dictionary mapping point indices to their topological indices.
        """
        msc = self.get_ms_complex()
        ti = {}
        for n in mt.nodes:
            ti[n] = topological_index(msc, n)
        return ti

    def get_persistence(self):
        """Returns the persistence of the landscape as a dictionary."""
        return get_persistence_dict(self.get_ms_complex())

    def show(self, **kwargs):
        """Renders a 3D representation of the loss landscape.

        See :obj:`landscaper.plots.surface_3d` for keyword arguments.

        Raises:
            ValueError: Thrown if the landscape has too many dimensions.
        """
        if self.dims == 2:
            return surface_3d(self.ranges, self.loss, **kwargs)
        else:
            raise ValueError(f"Cannot visualize a landscape with {self.dims} dimensions.")

    def show_profile(self, **kwargs):
        """Renders the topological profile of the landscape.

        See :obj:`landscaper.plots.topological_profile` for more details.
        """
        mt = self.get_sublevel_tree()
        profile = generate_profile(mt)
        return topology_profile(profile, **kwargs)

    def show_contour(self, **kwargs):
        """Renders a contour plot of the landscape.

        See :obj:`landscaper.plots.contour` for more details.
        """
        return contour(self.ranges, self.loss, **kwargs)

    def show_persistence_barcode(self, **kwargs):
        """Renders the persistence barcode of the landscape.

        See :obj:`landscaper.plots.persistence_barcode` for more details.
        """
        msc = self.get_ms_complex()
        return persistence_barcode(msc, **kwargs)

    def smad(self, normalize: bool = False, weighted: bool = False) -> float:
        """Calculates the Saddle-Minimum Average Distance (SMAD) for the landscape.
        See our publication for more details.

        Args:
            normalize (boolean): If true, divides each saddle-minimum gap by the total range of the loss values.
            weighted (boolean): If true, weights each saddle-minimum gap by the volume of the basin created by the saddle-minimum pair.

        Returns:
            (float) A descriptor of the smoothness of the landscape.
        """
        ct = self.get_contour_tree()
        ti = self.get_topological_indices(ct)
        msc = self.get_ms_complex()

        if len(ct.branches) == 0:
            return 0.0

        tot = len(self.loss.flatten())
        # branch persistence
        um = msc.get_unstable_manifolds()
        sp = saddle_minima_pairs(ct, ti)
        bp = np.empty(len(sp))
        vol = np.empty(len(sp))

        for i, (n1, n2) in enumerate(sp):
            minima = n1 if (ti[n1] == 0) else n2
            gap = abs(ct.nodes[n1] - ct.nodes[n2])
            if normalize:
                gap = gap / self.loss_range
            vol[i] = len(um[minima]) / tot
            bp[i] = gap

        if not weighted:
            return np.mean(bp)

        bp = np.log(bp)
        vol = np.log(vol)

        x = bp + vol
        x = np.sum(np.exp(x)) / len(x)

        return x

    def persistence_range(self) -> float:
        """
        Calculates the difference in persistence between the root
        of the merge tree and the global minimum.
        """
        msc = self.get_ms_complex()
        p = msc.persistences
        return abs(p[-1] - p[0])

__init__(loss, ranges)

Initializes a LossLandscape object.

Parameters:

Name Type Description Default
loss ArrayLike

A numpy array representing the loss values of the landscape.

required
ranges ArrayLike

A list of numpy arrays representing the ranges of each dimension of the landscape.

required

Raises:

Type Description
ValueError

If the dimensions of the loss array do not match the number of coordinates.

Source code in src/landscaper/landscape.py
def __init__(self, loss: npt.ArrayLike, ranges: npt.ArrayLike) -> None:
    """Initializes a LossLandscape object.

    Args:
        loss (npt.ArrayLike): A numpy array representing the loss values of the landscape.
        ranges (npt.ArrayLike): A list of numpy arrays representing the ranges of each dimension of the landscape.

    Raises:
        ValueError: If the dimensions of the loss array do not match the number of coordinates.
    """
    self.loss = loss
    # converts meshgrid output of arbitrary dimensions into list of coordinates
    grid = np.meshgrid(*ranges)
    self.coords = np.array([list(z) for z in zip(*(x.flat for x in grid), strict=False)])

    if self.coords.shape[0] != np.multiply.reduce(self.loss.shape):
        raise ValueError(
            f"Loss dimensions do not match coordinate dimensions: Loss - {self.loss.shape}; "
            f"Coordinates - {self.coords.shape}"
        )

    self.ranges = ranges
    self.dims = self.coords.shape[1]
    self.ms_complex = None
    self.sub_tree = None
    self.super_tree = None
    self.contour_tree = None
    self.topological_indices = None
    self.loss_range = np.max(loss) - np.min(loss)

compute(*args, **kwargs) staticmethod

Computes a loss landscape and directly creates a LossLandscape object.

See landscaper.compute for more information.

Returns:

Type Description
LossLandscape

(LossLandscape) A LossLandscape object.

Source code in src/landscaper/landscape.py
@staticmethod
def compute(*args, **kwargs) -> "LossLandscape":
    """Computes a loss landscape and directly creates a LossLandscape object.

    See `landscaper.compute` for more information.

    Returns:
        (LossLandscape) A LossLandscape object.
    """
    loss, coords = compute_loss_landscape(*args, **kwargs)
    return LossLandscape(loss, coords)

get_ms_complex()

Gets the MorseSmaleComplex corresponding to the loss landscape.

Returns:

Type Description
MorseSmaleComplex

A tp.MorseSmaleComplex.

Source code in src/landscaper/landscape.py
def get_ms_complex(self) -> tp.MorseSmaleComplex:
    """Gets the MorseSmaleComplex corresponding to the loss landscape.

    Returns:
        A tp.MorseSmaleComplex.
    """
    if self.ms_complex is None:
        ms_complex = tp.MorseSmaleComplex(
            graph=ngl.EmptyRegionGraph(beta=1.0, relaxed=False, p=2.0),
            gradient="steepest",
        )
        ms_complex.build(np.array(self.coords), self.loss.flatten())
        self.ms_complex = ms_complex
    return self.ms_complex

get_persistence()

Returns the persistence of the landscape as a dictionary.

Source code in src/landscaper/landscape.py
def get_persistence(self):
    """Returns the persistence of the landscape as a dictionary."""
    return get_persistence_dict(self.get_ms_complex())

get_sublevel_tree()

Gets the merge tree corresponding to the minima of the loss landscape.

Returns:

Type Description
MergeTree

A tp.MergeTree object corresponding to the minima of the loss landscape.

Source code in src/landscaper/landscape.py
def get_sublevel_tree(self) -> tp.MergeTree:
    """Gets the merge tree corresponding to the minima of the loss landscape.

    Returns:
        A tp.MergeTree object corresponding to the minima of the loss landscape.
    """
    if self.sub_tree is None:
        self.sub_tree = merge_tree(self.loss, self.coords)
    return self.sub_tree

get_super_tree()

Gets the merge tree corresponding to the maxima of the loss landscape.

Returns:

Type Description
MergeTree

A tp.MergeTree object corresponding to the maxima of the loss landscape.

Source code in src/landscaper/landscape.py
def get_super_tree(self) -> tp.MergeTree:
    """Gets the merge tree corresponding to the maxima of the loss landscape.

    Returns:
        A tp.MergeTree object corresponding to the maxima of the loss landscape.
    """
    if self.super_tree is None:
        self.super_tree = merge_tree(self.loss, self.coords, direction=-1)
    return self.super_tree

get_topological_indices(mt)

Returns a dictionary that maps point indices to their topological indices.

Returns:

Type Description
dict[int, int]

A dictionary mapping point indices to their topological indices.

Source code in src/landscaper/landscape.py
def get_topological_indices(self, mt) -> dict[int, int]:
    """Returns a dictionary that maps point indices to their topological indices.

    Returns:
        (dict[int, int]): A dictionary mapping point indices to their topological indices.
    """
    msc = self.get_ms_complex()
    ti = {}
    for n in mt.nodes:
        ti[n] = topological_index(msc, n)
    return ti

load_from_npz(fp) staticmethod

Creates a LossLandscape object directly from an .npz file.

Parameters:

Name Type Description Default
fp str

path to the file.

required

Returns:

Type Description
LossLandscape

(LossLandscape) A LossLandscape object created from the file.

Source code in src/landscaper/landscape.py
@staticmethod
def load_from_npz(fp: str) -> "LossLandscape":
    """Creates a LossLandscape object directly from an `.npz` file.

    Args:
        fp (str): path to the file.

    Returns:
        (LossLandscape) A LossLandscape object created from the file.
    """
    loss, coords = load_landscape(fp)
    return LossLandscape(loss, coords)

persistence_range()

Calculates the difference in persistence between the root of the merge tree and the global minimum.

Source code in src/landscaper/landscape.py
def persistence_range(self) -> float:
    """
    Calculates the difference in persistence between the root
    of the merge tree and the global minimum.
    """
    msc = self.get_ms_complex()
    p = msc.persistences
    return abs(p[-1] - p[0])

save(filename)

Saves the loss and coordinates of the landscape to the specified path for later use.

Parameters:

Name Type Description Default
filename str

path to save the landscape to.

required
Source code in src/landscaper/landscape.py
def save(self, filename: str) -> None:
    """Saves the loss and coordinates of the landscape to the specified path for later use.

    Args:
        filename (str): path to save the landscape to.
    """
    np.savez(filename, loss=self.loss, coordinates=self.ranges)

show(**kwargs)

Renders a 3D representation of the loss landscape.

See :obj:landscaper.plots.surface_3d for keyword arguments.

Raises:

Type Description
ValueError

Thrown if the landscape has too many dimensions.

Source code in src/landscaper/landscape.py
def show(self, **kwargs):
    """Renders a 3D representation of the loss landscape.

    See :obj:`landscaper.plots.surface_3d` for keyword arguments.

    Raises:
        ValueError: Thrown if the landscape has too many dimensions.
    """
    if self.dims == 2:
        return surface_3d(self.ranges, self.loss, **kwargs)
    else:
        raise ValueError(f"Cannot visualize a landscape with {self.dims} dimensions.")

show_contour(**kwargs)

Renders a contour plot of the landscape.

See :obj:landscaper.plots.contour for more details.

Source code in src/landscaper/landscape.py
def show_contour(self, **kwargs):
    """Renders a contour plot of the landscape.

    See :obj:`landscaper.plots.contour` for more details.
    """
    return contour(self.ranges, self.loss, **kwargs)

show_persistence_barcode(**kwargs)

Renders the persistence barcode of the landscape.

See :obj:landscaper.plots.persistence_barcode for more details.

Source code in src/landscaper/landscape.py
def show_persistence_barcode(self, **kwargs):
    """Renders the persistence barcode of the landscape.

    See :obj:`landscaper.plots.persistence_barcode` for more details.
    """
    msc = self.get_ms_complex()
    return persistence_barcode(msc, **kwargs)

show_profile(**kwargs)

Renders the topological profile of the landscape.

See :obj:landscaper.plots.topological_profile for more details.

Source code in src/landscaper/landscape.py
def show_profile(self, **kwargs):
    """Renders the topological profile of the landscape.

    See :obj:`landscaper.plots.topological_profile` for more details.
    """
    mt = self.get_sublevel_tree()
    profile = generate_profile(mt)
    return topology_profile(profile, **kwargs)

smad(normalize=False, weighted=False)

Calculates the Saddle-Minimum Average Distance (SMAD) for the landscape. See our publication for more details.

Parameters:

Name Type Description Default
normalize boolean

If true, divides each saddle-minimum gap by the total range of the loss values.

False
weighted boolean

If true, weights each saddle-minimum gap by the volume of the basin created by the saddle-minimum pair.

False

Returns:

Type Description
float

(float) A descriptor of the smoothness of the landscape.

Source code in src/landscaper/landscape.py
def smad(self, normalize: bool = False, weighted: bool = False) -> float:
    """Calculates the Saddle-Minimum Average Distance (SMAD) for the landscape.
    See our publication for more details.

    Args:
        normalize (boolean): If true, divides each saddle-minimum gap by the total range of the loss values.
        weighted (boolean): If true, weights each saddle-minimum gap by the volume of the basin created by the saddle-minimum pair.

    Returns:
        (float) A descriptor of the smoothness of the landscape.
    """
    ct = self.get_contour_tree()
    ti = self.get_topological_indices(ct)
    msc = self.get_ms_complex()

    if len(ct.branches) == 0:
        return 0.0

    tot = len(self.loss.flatten())
    # branch persistence
    um = msc.get_unstable_manifolds()
    sp = saddle_minima_pairs(ct, ti)
    bp = np.empty(len(sp))
    vol = np.empty(len(sp))

    for i, (n1, n2) in enumerate(sp):
        minima = n1 if (ti[n1] == 0) else n2
        gap = abs(ct.nodes[n1] - ct.nodes[n2])
        if normalize:
            gap = gap / self.loss_range
        vol[i] = len(um[minima]) / tot
        bp[i] = gap

    if not weighted:
        return np.mean(bp)

    bp = np.log(bp)
    vol = np.log(vol)

    x = bp + vol
    x = np.sum(np.exp(x)) / len(x)

    return x