Skip to content

Shapes

All shapes inherit from BaseShape and implement the same callable interface: calling an instance with an array of labels returns a pairwise distance matrix.

Base

smds.shapes.base_shape.BaseShape

Bases: BaseEstimator, ABC

Abstract base class for defining manifold shapes.

This class serves as a template for transforming input labels (y) into a pairwise distance matrix that represents the geometry of a specific shape (manifold). It handles input validation, optional normalization, and structural integrity checks on the output matrix.

Subclasses must implement: - y_ndim: Property defining expected input dimensionality. - normalize_labels: Property flag for normalization behavior. - _compute_distances: The core logic for mapping labels to distances.

Attributes:

Name Type Description
y_ndim int or tuple of int

Abstract property. The expected dimensionality of the input labels.

normalize_labels bool

Abstract property. Whether to normalize inputs before computation.

Source code in smds/shapes/base_shape.py
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
class BaseShape(BaseEstimator, ABC):  # type: ignore[misc]
    """
    Abstract base class for defining manifold shapes.

    This class serves as a template for transforming input labels (y) into
    a pairwise distance matrix that represents the geometry of a specific
    shape (manifold). It handles input validation, optional normalization,
    and structural integrity checks on the output matrix.

    Subclasses must implement:
    - `y_ndim`: Property defining expected input dimensionality.
    - `normalize_labels`: Property flag for normalization behavior.
    - `_compute_distances`: The core logic for mapping labels to distances.

    Attributes
    ----------
    y_ndim : int or tuple of int
        Abstract property. The expected dimensionality of the input labels.
    normalize_labels : bool
        Abstract property. Whether to normalize inputs before computation.
    """

    @property
    @abstractmethod
    def y_ndim(self) -> int | tuple[int, ...]:
        """
        Get the required dimensionality of the input labels `y`.

        Returns
        -------
        int or tuple of int
            The number of dimensions expected for the input array.
            - 1: 1D array (e.g., time series, clusters).
            - 2: 2D array (e.g., lat/lon coordinates, hierarchical levels).

            A tuple declares that several are accepted, e.g. ``(1, 2)`` for a
            shape defined for both scalar labels and coordinate arrays. Test it
            with ``ndim in np.atleast_1d(shape.y_ndim)``, not for equality.
        """
        pass

    @property
    @abstractmethod
    def normalize_labels(self) -> bool:
        """
        Get the flag indicating whether input labels should be normalized.

        Returns
        -------
        bool
            True if `_do_normalize_labels` should be called in `__call__`,
            False otherwise.
        """
        pass

    @staticmethod
    def _do_normalize_labels(y: NDArray[np.float64]) -> NDArray[np.float64]:
        """
        Perform default Min-Max normalization on the input labels.

        Maps the input values to the range [0, 1]. If the input has no
        variance (all values are equal), a zero array is returned.

        Parameters
        ----------
        y : NDArray[np.float64]
            The input label array.

        Returns
        -------
        NDArray[np.float64]
            The normalized array.
        """
        max_y = np.max(y)
        min_y = np.min(y)
        if max_y == min_y:
            return np.zeros_like(y, dtype=float)
        return (y - min_y) / (max_y - min_y)

    def __call__(self, y: NDArray[np.float64]) -> NDArray[np.float64]:
        """
        Compute the pairwise distance matrix for the given labels.

        This is the main entry point (Template Method). It performs the
        following steps:
        1. Validates the input `y` (dimensions and emptiness).
        2. Normalizes `y` if `self.normalize_labels` is True.
        3. Calls the subclass implementation of `_compute_distances`.
        4. Validates that the output is a square matrix.
        5. Enforces a zero diagonal.

        Parameters
        ----------
        y : NDArray[np.float64]
            The input labels or coordinates used to position points on the
            manifold. Must match `self.y_ndim`.

        Returns
        -------
        NDArray[np.float64]
            A square matrix of shape (n_samples, n_samples) containing
            pairwise Euclidean distances on the defined manifold.

        Raises
        ------
        ValueError
            If `_compute_distances` returns a matrix with incorrect dimensions.
        """
        y_proc: NDArray[np.float64] = self._validate_input(y)
        n: int = len(y_proc)

        if self.normalize_labels:
            y_proc = self._do_normalize_labels(y_proc)

        distance: NDArray[np.float64] = self._compute_distances(y_proc)

        if distance.shape != (n, n):
            raise ValueError(
                f"_compute_distances must return a square matrix of shape ({n}, {n}), but got shape {distance.shape}."
            )

        np.fill_diagonal(distance, 0)
        return distance

    def _validate_input(self, y: NDArray[np.float64]) -> NDArray[np.float64]:
        """
        Validate input array dimensions and content.

        Parameters
        ----------
        y : NDArray[np.float64]
            The raw input array.

        Returns
        -------
        NDArray[np.float64]
            The validated array, cast to float64.

        Raises
        ------
        ValueError
            If the input array is empty or does not match `self.y_ndim`.
        """
        y_proc: NDArray[np.float64] = np.asarray(y, dtype=np.float64)

        if y_proc.size == 0:
            raise ValueError("Input 'y' cannot be empty.")

        accepted = np.atleast_1d(self.y_ndim)

        if y_proc.ndim not in accepted:
            raise ValueError(
                f"Input 'y' must be {'- or '.join(map(str, accepted))}-dimensional, "
                f"but got shape {y_proc.shape} with {y_proc.ndim} dimensions."
            )

        return y_proc

    @abstractmethod
    def _compute_distances(self, y: NDArray[np.float64]) -> NDArray[np.float64]:
        """
        Compute the specific pairwise distances for the implemented shape.

        This abstract method must be overridden by subclasses to define the
        geometry of the manifold.

        Parameters
        ----------
        y : NDArray[np.float64]
            The processed (and potentially normalized) input labels.

        Returns
        -------
        NDArray[np.float64]
            A square pairwise distance matrix.
        """
        raise NotImplementedError()

y_ndim abstractmethod property

y_ndim: int | tuple[int, ...]

Get the required dimensionality of the input labels y.

Returns:

Type Description
int or tuple of int

The number of dimensions expected for the input array. - 1: 1D array (e.g., time series, clusters). - 2: 2D array (e.g., lat/lon coordinates, hierarchical levels).

A tuple declares that several are accepted, e.g. (1, 2) for a shape defined for both scalar labels and coordinate arrays. Test it with ndim in np.atleast_1d(shape.y_ndim), not for equality.

normalize_labels abstractmethod property

normalize_labels: bool

Get the flag indicating whether input labels should be normalized.

Returns:

Type Description
bool

True if _do_normalize_labels should be called in __call__, False otherwise.

__call__

__call__(y: NDArray[float64]) -> NDArray[np.float64]

Compute the pairwise distance matrix for the given labels.

This is the main entry point (Template Method). It performs the following steps: 1. Validates the input y (dimensions and emptiness). 2. Normalizes y if self.normalize_labels is True. 3. Calls the subclass implementation of _compute_distances. 4. Validates that the output is a square matrix. 5. Enforces a zero diagonal.

Parameters:

Name Type Description Default
y NDArray[float64]

The input labels or coordinates used to position points on the manifold. Must match self.y_ndim.

required

Returns:

Type Description
NDArray[float64]

A square matrix of shape (n_samples, n_samples) containing pairwise Euclidean distances on the defined manifold.

Raises:

Type Description
ValueError

If _compute_distances returns a matrix with incorrect dimensions.

Source code in smds/shapes/base_shape.py
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
def __call__(self, y: NDArray[np.float64]) -> NDArray[np.float64]:
    """
    Compute the pairwise distance matrix for the given labels.

    This is the main entry point (Template Method). It performs the
    following steps:
    1. Validates the input `y` (dimensions and emptiness).
    2. Normalizes `y` if `self.normalize_labels` is True.
    3. Calls the subclass implementation of `_compute_distances`.
    4. Validates that the output is a square matrix.
    5. Enforces a zero diagonal.

    Parameters
    ----------
    y : NDArray[np.float64]
        The input labels or coordinates used to position points on the
        manifold. Must match `self.y_ndim`.

    Returns
    -------
    NDArray[np.float64]
        A square matrix of shape (n_samples, n_samples) containing
        pairwise Euclidean distances on the defined manifold.

    Raises
    ------
    ValueError
        If `_compute_distances` returns a matrix with incorrect dimensions.
    """
    y_proc: NDArray[np.float64] = self._validate_input(y)
    n: int = len(y_proc)

    if self.normalize_labels:
        y_proc = self._do_normalize_labels(y_proc)

    distance: NDArray[np.float64] = self._compute_distances(y_proc)

    if distance.shape != (n, n):
        raise ValueError(
            f"_compute_distances must return a square matrix of shape ({n}, {n}), but got shape {distance.shape}."
        )

    np.fill_diagonal(distance, 0)
    return distance

Continuous Shapes

CircularShape

smds.shapes.continuous_shapes.circular.CircularShape

Bases: BaseShape

Compute Euclidean (chord) distances for continuous data on a circular manifold.

This shape wraps continuous normalized values onto a circle and calculates the straight-line (chord) distance between them through the circle's interior. This differs from the arc length (geodesic) distance.

Parameters:

Name Type Description Default
radious float

The radius of the circle. Default is 1.0. (Note: The current implementation calculates distances for a unit circle regardless of this parameter).

1.0
normalize_labels bool

Whether to normalize labels to the range [0, 1]. Default is True.

True

Attributes:

Name Type Description
y_ndim int

Dimensionality of input labels (1). Expects scalar values.

Source code in smds/shapes/continuous_shapes/circular.py
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
class CircularShape(BaseShape):
    """
    Compute Euclidean (chord) distances for continuous data on a circular manifold.

    This shape wraps continuous normalized values onto a circle and calculates
    the straight-line (chord) distance between them through the circle's interior.
    This differs from the arc length (geodesic) distance.

    Parameters
    ----------
    radious : float, optional
        The radius of the circle. Default is 1.0.
        (Note: The current implementation calculates distances for a unit circle
        regardless of this parameter).
    normalize_labels : bool, optional
        Whether to normalize labels to the range [0, 1]. Default is True.

    Attributes
    ----------
    y_ndim : int
        Dimensionality of input labels (1). Expects scalar values.
    """

    y_ndim = 1

    @property
    def normalize_labels(self) -> bool:
        """bool: Whether input labels are normalized."""
        return self._normalize_labels

    def __init__(self, radious: float = 1.0, normalize_labels: bool = True):
        self.radious = radious
        self._normalize_labels = normalize_labels

    def _compute_distances(self, y: NDArray[np.float64]) -> NDArray[np.float64]:
        """
        Compute pairwise chord distances.

        The labels are treated as fractions of a circle (0 to 1).
        The distance is calculated as:
        \\[ D_{ij} = 2 \\sin(\\pi \\cdot \\delta_{ij}) \\]
        where \\(\\delta_{ij} = \\min(|y_i - y_j|, 1 - |y_i - y_j|)\\) is the shortest
        arc fraction between points.

        Parameters
        ----------
        y : NDArray[np.float64]
            Input 1D array of labels (normalized).

        Returns
        -------
        NDArray[np.float64]
            Pairwise Euclidean distance matrix (chord lengths).
        """
        delta: NDArray[np.float64] = np.abs(y[:, None] - y[None, :])
        delta = np.minimum(delta, 1 - delta)

        distance: NDArray[np.float64] = 2 * np.sin(np.pi * delta)
        return distance

normalize_labels property

normalize_labels: bool

bool: Whether input labels are normalized.

SemicircularShape

smds.shapes.continuous_shapes.semicircular.SemicircularShape

Bases: BaseShape

Compute Euclidean (chord) distances for points mapped to a semicircle.

This shape maps normalized 1D scalar values to angles on a unit semicircle (ranging from 0 to \(\pi\)) and computes the straight-line (chord) distance between them.

Parameters:

Name Type Description Default
normalize_labels bool

Whether to normalize labels to the range [0, 1]. Default is True.

True

Attributes:

Name Type Description
y_ndim int

Dimensionality of input labels (1). Expects 1D scalar values.

Source code in smds/shapes/continuous_shapes/semicircular.py
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
class SemicircularShape(BaseShape):
    r"""
    Compute Euclidean (chord) distances for points mapped to a semicircle.

    This shape maps normalized 1D scalar values to angles on a unit semicircle
    (ranging from 0 to \(\pi\)) and computes the straight-line (chord) distance
    between them.

    Parameters
    ----------
    normalize_labels : bool, optional
        Whether to normalize labels to the range [0, 1]. Default is True.

    Attributes
    ----------
    y_ndim : int
        Dimensionality of input labels (1). Expects 1D scalar values.
    """

    y_ndim = 1

    @property
    def normalize_labels(self) -> bool:
        """bool: Whether input labels are normalized."""
        return self._normalize_labels

    def __init__(self, normalize_labels: bool = True):
        self._normalize_labels = normalize_labels

    def _validate_input(self, y: NDArray[np.float64]) -> NDArray[np.float64]:
        """
        Validate input is 1D.

        Parameters
        ----------
        y : NDArray[np.float64]
            Input array.

        Returns
        -------
        NDArray[np.float64]
            Validated flat 1D array.

        Raises
        ------
        ValueError
            If `y` is empty or has more than one column.
        """
        y_proc: NDArray[np.float64] = np.asarray(y, dtype=np.float64)

        if y_proc.size == 0:
            raise ValueError("Input 'y' cannot be empty.")

        if y_proc.ndim > 1 and y_proc.shape[1] > 1:
            raise ValueError(
                f"Input 'y' for SemicircularShape must be 1-dimensional (n_samples,) "
                f"or (n_samples, 1), but got shape {y_proc.shape}."
            )

        return y_proc.ravel()

    def _compute_distances(self, y: NDArray[np.float64]) -> NDArray[np.float64]:
        """
        Compute pairwise chord distances on the unit semicircle.

        The distance is calculated as:
        \\[ D_{ij} = 2 \\sin\\left(\\frac{\\pi}{2} |y_i - y_j|\\right) \\]
        This corresponds to mapping \\(y\\) (assumed in \\([0, 1]\\)) to angles \\(\\theta \\in [0, \\pi]\\)
        and finding the chord length.

        Parameters
        ----------
        y : NDArray[np.float64]
            Input 1D array of labels (typically normalized).

        Returns
        -------
        NDArray[np.float64]
            Pairwise Euclidean distance matrix.
        """
        y_flat = y.ravel()

        delta: NDArray[np.float64] = np.abs(y_flat[:, None] - y_flat[None, :])

        distance_matrix: NDArray[np.float64] = 2 * np.sin((np.pi / 2) * delta)

        return distance_matrix

normalize_labels property

normalize_labels: bool

bool: Whether input labels are normalized.

EuclideanShape

smds.shapes.continuous_shapes.euclidean.EuclideanShape

Bases: BaseShape

Compute Euclidean (linear) distances for continuous data in n dimensions.

This shape models data lying in a flat Euclidean space of arbitrary dimensionality. Inputs of shape (n_samples,) are treated as points on a line, inputs of shape (n_samples, n_features) as points in :math:\mathbb{R}^{n\_features}.

Reference: Table 1 in "Shape Happens" paper (referred to as 'linear').

Parameters:

Name Type Description Default
normalize_labels bool

Whether to normalize labels to the range [0, 1]. Default is True.

True

Attributes:

Name Type Description
y_ndim tuple of int

Accepted label dimensionalities, (1, 2): either scalar labels of shape (n_samples,) or coordinate arrays of shape (n_samples, n_features).

Notes

:class:~smds.SupervisedMDS defaults n_components to 1 for this shape, since scalar labels are the common case. With (n_samples, n_features) labels you almost always want to pass n_components=n_features explicitly; otherwise the embedding is truncated to a single dimension.

Source code in smds/shapes/continuous_shapes/euclidean.py
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
class EuclideanShape(BaseShape):
    """
    Compute Euclidean (linear) distances for continuous data in n dimensions.

    This shape models data lying in a flat Euclidean space of arbitrary
    dimensionality. Inputs of shape (n_samples,) are treated as points on a
    line, inputs of shape (n_samples, n_features) as points in
    :math:`\\mathbb{R}^{n\\_features}`.

    Reference: Table 1 in "Shape Happens" paper (referred to as 'linear').

    Parameters
    ----------
    normalize_labels : bool, optional
        Whether to normalize labels to the range [0, 1]. Default is True.

    Attributes
    ----------
    y_ndim : tuple of int
        Accepted label dimensionalities, ``(1, 2)``: either scalar labels of
        shape (n_samples,) or coordinate arrays of shape
        (n_samples, n_features).

    Notes
    -----
    :class:`~smds.SupervisedMDS` defaults ``n_components`` to 1 for this shape,
    since scalar labels are the common case. With (n_samples, n_features)
    labels you almost always want to pass ``n_components=n_features``
    explicitly; otherwise the embedding is truncated to a single dimension.
    """

    y_ndim = (1, 2)

    @property
    def normalize_labels(self) -> bool:
        """bool: Whether input labels are normalized."""
        return self._normalize_labels

    def __init__(self, normalize_labels: bool = True):
        self._normalize_labels = normalize_labels

    def _compute_distances(self, y: NDArray[np.float64]) -> NDArray[np.float64]:
        """
        Compute pairwise Euclidean distances.

        Calculates:

        .. math::

            D_{ij} = \\sqrt{\\sum_{k} (y_{ik} - y_{jk})^2}

        Parameters
        ----------
        y : NDArray[np.float64]
            Input array of shape (n_samples,) or (n_samples, n_features).

        Returns
        -------
        NDArray[np.float64]
            Pairwise Euclidean distance matrix of shape (n_samples, n_samples).
        """
        points = y.reshape(len(y), -1)

        distance_matrix: NDArray[np.float64] = squareform(pdist(points))

        return distance_matrix

normalize_labels property

normalize_labels: bool

bool: Whether input labels are normalized.

LogLinearShape

smds.shapes.continuous_shapes.log_linear.LogLinearShape

Bases: BaseShape

Compute distances based on logarithmic scaling.

This shape models data where differences are more significant at smaller scales than at larger scales (e.g., sound intensity, earthquake magnitude). The distance is defined as the absolute difference between the logarithms of the values.

Reference: Table 1 in the "Shape Happens" paper.

Parameters:

Name Type Description Default
normalize_labels bool

Whether to normalize labels using the base class logic. Default is False.

False

Attributes:

Name Type Description
y_ndim int

Dimensionality of input labels (1). Expects non-negative continuous values.

Source code in smds/shapes/continuous_shapes/log_linear.py
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
class LogLinearShape(BaseShape):
    """
    Compute distances based on logarithmic scaling.

    This shape models data where differences are more significant at smaller scales
    than at larger scales (e.g., sound intensity, earthquake magnitude).
    The distance is defined as the absolute difference between the logarithms
    of the values.

    Reference: Table 1 in the "Shape Happens" paper.

    Parameters
    ----------
    normalize_labels : bool, optional
        Whether to normalize labels using the base class logic. Default is False.

    Attributes
    ----------
    y_ndim : int
        Dimensionality of input labels (1). Expects non-negative continuous values.
    """

    y_ndim = 1

    @property
    def normalize_labels(self) -> bool:
        """bool: Whether input labels are normalized."""
        return self._normalize_labels

    def __init__(self, normalize_labels: bool = False):
        self._normalize_labels = normalize_labels

    def _validate_input(self, y: NDArray[np.float64]) -> NDArray[np.float64]:
        """
        Validate input is 1D and contains only non-negative values.

        Parameters
        ----------
        y : NDArray[np.float64]
            Input 1D array of values.

        Returns
        -------
        NDArray[np.float64]
            Validated flat array.

        Raises
        ------
        ValueError
            If `y` is empty, multi-dimensional, or contains negative values.
        """
        y_proc: NDArray[np.float64] = np.asarray(y, dtype=np.float64)

        if y_proc.size == 0:
            raise ValueError("Input 'y' cannot be empty.")

        if y_proc.ndim > 1 and y_proc.shape[1] > 1:
            raise ValueError(
                f"Input 'y' for LogLinearShape must be 1-dimensional (n_samples,) "
                f"or (n_samples, 1), but got shape {y_proc.shape}."
            )

        y_flat = y_proc.ravel()

        if np.any(y_flat < 0):
            raise ValueError("Input 'y' for LogLinearShape cannot contain negative values.")

        return y_flat

    def _compute_distances(self, y: NDArray[np.float64]) -> NDArray[np.float64]:
        """
        Compute pairwise logarithmic distances.

        Calculates \\(D_{ij} = |\\log(y_i + 1) - \\log(y_j + 1)|\\).
        A shift of 1.0 is added to avoid \\(\\log(0)\\).

        Parameters
        ----------
        y : NDArray[np.float64]
            Input array of non-negative values.

        Returns
        -------
        NDArray[np.float64]
            Pairwise distance matrix representing the difference in magnitude.
        """
        y_flat = y.ravel()
        y_log = np.log(y_flat + 1.0)
        distance_matrix: NDArray[np.float64] = np.abs(y_log[:, None] - y_log[None, :])

        return distance_matrix

normalize_labels property

normalize_labels: bool

bool: Whether input labels are normalized.

SpiralShape

smds.shapes.continuous_shapes.spiral_shape.SpiralShape

Bases: BaseShape

Arrange points in an Archimedean spiral pattern.

This class generates a shape where points are arranged along a spiral trajectory defined by the equation \(r = a + b\theta\). The distance metric is computed based on the Euclidean distance between these points in Cartesian coordinates.

Parameters:

Name Type Description Default
initial_radius float

The starting radius of the spiral (offset from the origin), corresponding to \(a\) in the Archimedean spiral equation. Default is 0.5.

0.5
growth_rate float

The rate at which the spiral expands away from the center for every radian of rotation, corresponding to \(b\) in the Archimedean spiral equation. Default is 1.0.

1.0
num_turns float

The total number of complete rotations the spiral makes. This scales the input labels mapping them to the angle \(\theta\). Default is 2.0.

2.0
normalize_labels bool

Whether to normalize the input labels y to the range [0, 1] before computing the spiral coordinates. Default is True.

True

Attributes:

Name Type Description
y_ndim int

The dimensionality of the label array expected by this shape (1).

initial_radius float

The configured starting radius.

growth_rate float

The configured growth rate.

num_turns float

The configured number of turns.

Source code in smds/shapes/continuous_shapes/spiral_shape.py
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
class SpiralShape(BaseShape):
    """
    Arrange points in an Archimedean spiral pattern.

    This class generates a shape where points are arranged along a spiral
    trajectory defined by the equation \\(r = a + b\\theta\\). The distance metric
    is computed based on the Euclidean distance between these points in
    Cartesian coordinates.

    Parameters
    ----------
    initial_radius : float, optional
        The starting radius of the spiral (offset from the origin), corresponding
        to \\(a\\) in the Archimedean spiral equation. Default is 0.5.
    growth_rate : float, optional
        The rate at which the spiral expands away from the center for every radian
        of rotation, corresponding to \\(b\\) in the Archimedean spiral equation.
        Default is 1.0.
    num_turns : float, optional
        The total number of complete rotations the spiral makes. This scales the
        input labels mapping them to the angle \\(\\theta\\). Default is 2.0.
    normalize_labels : bool, optional
        Whether to normalize the input labels `y` to the range [0, 1] before
        computing the spiral coordinates. Default is True.

    Attributes
    ----------
    y_ndim : int
        The dimensionality of the label array expected by this shape (1).
    initial_radius : float
        The configured starting radius.
    growth_rate : float
        The configured growth rate.
    num_turns : float
        The configured number of turns.
    """

    y_ndim = 1

    @property
    def normalize_labels(self) -> bool:
        """
        bool: Whether input labels are normalized to [0, 1].
        """
        return self._normalize_labels

    def __init__(
        self,
        initial_radius: float = 0.5,
        growth_rate: float = 1.0,
        num_turns: float = 2.0,
        normalize_labels: bool = True,
    ) -> None:
        self.initial_radius = initial_radius
        self.growth_rate = growth_rate
        self.num_turns = num_turns
        self._normalize_labels = normalize_labels

    @staticmethod
    def _do_normalize_labels(y: NDArray[np.float64]) -> NDArray[np.float64]:
        """
        Normalize an array of labels to the range [0, 1].

        If the range of `y` (peak-to-peak) is 0, an array of zeros is returned
        to avoid division by zero.

        Parameters
        ----------
        y : NDArray[np.float64]
            Input array of labels to be normalized.

        Returns
        -------
        NDArray[np.float64]
            The normalized array with values scaled between 0 and 1.
        """
        y_range = np.ptp(y)
        if y_range == 0:
            zero_array: NDArray[np.float64] = np.zeros_like(y)
            return zero_array
        result: NDArray[np.float64] = (y - y.min()) / y_range
        return result

    def _compute_distances(self, y: NDArray[np.float64]) -> NDArray[np.float64]:
        """
        Compute the pairwise distance matrix for points mapped to the spiral.

        The input labels `y` are first converted to polar coordinates \\((r, \\theta)\\)
        using the class parameters:
        \\[ \\theta = y \\times 2\\pi \\times \\text{num\\_turns} \\]
        \\[ r = \\text{initial\\_radius} + \\text{growth\\_rate} \\times \\theta \\]

        These polar coordinates are converted to Cartesian coordinates, and the
        Euclidean distances between all pairs of points are calculated.

        Parameters
        ----------
        y : NDArray[np.float64]
            The input labels/values to map onto the spiral.

        Returns
        -------
        NDArray[np.float64]
            A 2D array representing the pairwise Euclidean distances between
            the transformed points.
        """
        theta = y * 2 * np.pi * self.num_turns
        radius = self.initial_radius + self.growth_rate * theta

        polar = PolarCoordinates(radius, theta)
        cartesian = polar.to_cartesian()

        result: NDArray[np.float64] = cartesian.compute_distances()
        return result

normalize_labels property

normalize_labels: bool

bool: Whether input labels are normalized to [0, 1].

KleinBottleShape

smds.shapes.continuous_shapes.klein_bottle.KleinBottleShape

Bases: BaseShape

Manifold hypothesis representing a Klein Bottle topology.

This shape assumes the data lies on a 2D surface that is non-orientable. Requires exactly 2 dimensions as (u, v) parameters. If < 2 dimensions, zeros are padded. If > 2 dimensions, raises an error. Maps these 2 dimensions to the unit square [0, 1] x [0, 1]. Computes pairwise distances respecting the Klein bottle identifications: - Top/Bottom edges match (Cylinder): (u, 0) ~ (u, 1) - Left/Right edges match with a Twist (Möbius): (0, v) ~ (1, 1-v)

Reference: Wolfram MathWorld: https://mathworld.wolfram.com/KleinBottle.html

Parameters:

Name Type Description Default
normalize_labels bool

Whether to normalize labels to the range [0, 1]. Default is True.

True

Attributes:

Name Type Description
y_ndim int

Dimensionality of input labels (2). Expects (u, v) parameters.

Source code in smds/shapes/continuous_shapes/klein_bottle.py
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
class KleinBottleShape(BaseShape):
    """
    Manifold hypothesis representing a Klein Bottle topology.

    This shape assumes the data lies on a 2D surface that is non-orientable.
    Requires exactly 2 dimensions as (u, v) parameters. If < 2 dimensions,
    zeros are padded. If > 2 dimensions, raises an error. Maps these 2
    dimensions to the unit square [0, 1] x [0, 1]. Computes pairwise distances
    respecting the Klein bottle identifications:
    - Top/Bottom edges match (Cylinder): (u, 0) ~ (u, 1)
    - Left/Right edges match with a Twist (Möbius): (0, v) ~ (1, 1-v)

    Reference: Wolfram MathWorld: https://mathworld.wolfram.com/KleinBottle.html

    Parameters
    ----------
    normalize_labels : bool, optional
        Whether to normalize labels to the range [0, 1]. Default is True.

    Attributes
    ----------
    y_ndim : int
        Dimensionality of input labels (2). Expects (u, v) parameters.
    """

    def __init__(self, normalize_labels: bool = True):
        self._normalize_labels_flag = normalize_labels

    @property
    def y_ndim(self) -> int:
        """int: Dimensionality of input labels (2). Expects (u, v) parameters."""
        return 2

    @property
    def normalize_labels(self) -> bool:
        """bool: Whether input labels are normalized."""
        return self._normalize_labels_flag

    def _validate_input(self, y: NDArray[np.float64]) -> NDArray[np.float64]:
        """
        Validate input and ensure exactly 2 dimensions for (u, v) parameters.

        Parameters
        ----------
        y : NDArray[np.float64]
            Input array of shape (n_samples,) or (n_samples, n_features).

        Returns
        -------
        NDArray[np.float64]
            Array of shape (n_samples, 2).

        Raises
        ------
        ValueError
            If ``y`` is empty or has more than 2 features.
        """
        y_proc = np.asarray(y, dtype=np.float64)

        if y_proc.size == 0:
            raise ValueError("Input 'y' cannot be empty.")

        if y_proc.ndim == 1:
            y_proc = y_proc.reshape(-1, 1)

        n_samples, n_features = y_proc.shape

        if n_features > 2:
            raise ValueError(
                f"Klein Bottle requires exactly 2 dimensions (u, v), but got {n_features} dimensions. "
                "Please provide y with shape (n_samples, 2)."
            )

        elif n_features < 2:
            zeros = np.zeros((n_samples, 2 - n_features))
            y_proc = np.hstack([y_proc, zeros])

        return y_proc

    def _compute_distances(self, y: NDArray[np.float64]) -> NDArray[np.float64]:
        """
        Compute geodesic distances on a flat Klein bottle.

        Considers all four identification cases (direct, cylinder wrap, Möbius
        twist, and combined wrap) and returns the minimum distance.
        Assumes ``y`` is normalized to [0, 1] × [0, 1].

        Parameters
        ----------
        y : NDArray[np.float64]
            Input array of shape (n_samples, 2) with columns (u, v).

        Returns
        -------
        NDArray[np.float64]
            Pairwise geodesic distance matrix of shape (n_samples, n_samples).
        """
        u = y[:, 0]  # Twist axis
        v = y[:, 1]  # Cylinder axis

        u1 = u.reshape(-1, 1)
        u2 = u.reshape(1, -1)
        v1 = v.reshape(-1, 1)
        v2 = v.reshape(1, -1)

        diff_u = np.abs(u1 - u2)
        diff_v = np.abs(v1 - v2)

        # 1. Direct
        dist_sq_direct = diff_u**2 + diff_v**2

        # 2. Cylinder Wrap (Wrap V)
        dist_sq_cylinder = diff_u**2 + (1.0 - diff_v) ** 2

        # 3. Möbius Twist (Wrap U -> Flip V)
        dist_u_twist = 1.0 - diff_u
        dist_v_twist = np.abs(v1 + v2 - 1.0)

        dist_sq_twist = dist_u_twist**2 + dist_v_twist**2

        # 4. Combined Wrap (Wrap U + Wrap V)
        dist_v_twist_wrap = 1.0 - dist_v_twist
        dist_sq_twist_wrap = dist_u_twist**2 + dist_v_twist_wrap**2

        # Minimum distance
        D_sq = np.minimum(dist_sq_direct, dist_sq_cylinder)
        D_sq = np.minimum(D_sq, dist_sq_twist)
        D_sq = np.minimum(D_sq, dist_sq_twist_wrap)

        result: NDArray[np.float64] = np.sqrt(D_sq)
        return result

y_ndim property

y_ndim: int

int: Dimensionality of input labels (2). Expects (u, v) parameters.

normalize_labels property

normalize_labels: bool

bool: Whether input labels are normalized.

TorusShape

smds.shapes.continuous_shapes.torus.TorusShape

Bases: BaseShape

Compute geodesic distances on a flat torus (T\ :sup:1 × T\ :sup:1) manifold.

Models data lying on the product of two circles. Input coordinates are mapped onto the unit square [0, 1] × [0, 1] with periodic boundary conditions in both directions:

  • (u, 0) ~ (u, 1) — cylinder wrap in the v direction.
  • (0, v) ~ (1, v) — cylinder wrap in the u direction.

If the input has more than 2 dimensions it is first reduced to 2 via PCA; if it has fewer than 2 dimensions zeros are padded.

Parameters:

Name Type Description Default
radii tuple of (float, float)

Scaling weights (r1, r2) for the two cyclic dimensions. Default is (1.0, 1.0) (flat Clifford torus).

(1.0, 1.0)
normalize_labels bool

Whether to normalize labels to the range [0, 1]. Default is True.

True

Attributes:

Name Type Description
y_ndim int

Dimensionality of input labels (2). Expects (u, v) parameters.

Source code in smds/shapes/continuous_shapes/torus.py
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
class TorusShape(BaseShape):
    """
    Compute geodesic distances on a flat torus (T\\ :sup:`1` × T\\ :sup:`1`) manifold.

    Models data lying on the product of two circles. Input coordinates are
    mapped onto the unit square [0, 1] × [0, 1] with periodic boundary
    conditions in both directions:

    - (u, 0) ~ (u, 1) — cylinder wrap in the v direction.
    - (0, v) ~ (1, v) — cylinder wrap in the u direction.

    If the input has more than 2 dimensions it is first reduced to 2 via PCA;
    if it has fewer than 2 dimensions zeros are padded.

    Parameters
    ----------
    radii : tuple of (float, float), optional
        Scaling weights (r1, r2) for the two cyclic dimensions.
        Default is ``(1.0, 1.0)`` (flat Clifford torus).
    normalize_labels : bool, optional
        Whether to normalize labels to the range [0, 1]. Default is True.

    Attributes
    ----------
    y_ndim : int
        Dimensionality of input labels (2). Expects (u, v) parameters.
    """

    def __init__(self, radii: tuple[float, float] = (1.0, 1.0), normalize_labels: bool = True):
        self._radii = radii
        self._normalize_labels_flag = normalize_labels

    @property
    def y_ndim(self) -> int:
        """int: Expected input dimensionality (2)."""
        return 2

    @property
    def radii(self) -> tuple[float, float]:
        """Tuple of (float, float): Scaling weights for the two cyclic dimensions."""
        return self._radii

    @property
    def normalize_labels(self) -> bool:
        """bool: Whether labels are normalized to the unit square."""
        return self._normalize_labels_flag

    def _validate_input(self, y: NDArray[np.float64]) -> NDArray[np.float64]:
        """
        Validate input and reduce or pad to exactly 2 dimensions.

        Parameters
        ----------
        y : NDArray[np.float64]
            Input array of shape (n_samples,) or (n_samples, n_features).

        Returns
        -------
        NDArray[np.float64]
            Array of shape (n_samples, 2) suitable for torus projection.

        Raises
        ------
        ValueError
            If ``y`` is empty.
        """
        y_proc = np.asarray(y, dtype=np.float64)

        if y_proc.size == 0:
            raise ValueError("Input 'y' cannot be empty.")

        if y_proc.ndim == 1:
            y_proc = y_proc.reshape(-1, 1)

        n_samples, n_features = y_proc.shape

        if n_features > 2:
            n_components = 2
            if n_samples < n_components:
                y_new = np.zeros((n_samples, 2))
                y_new[:, : min(n_features, 2)] = y_proc[:, : min(n_features, 2)]
                y_proc = y_new
            else:
                pca = PCA(n_components=2)
                y_proc = pca.fit_transform(y_proc)
        elif n_features < 2:
            zeros = np.zeros((n_samples, 2 - n_features))
            y_proc = np.hstack([y_proc, zeros])

        return y_proc

    def _compute_distances(self, y: NDArray[np.float64]) -> NDArray[np.float64]:
        """
        Compute geodesic distances on the flat torus.

        Calculates the shortest-path distance respecting periodic boundaries
        in both u and v directions:

        .. math::

            D_{ij} = \\sqrt{(r_1 \\cdot \\delta u_{ij})^2 + (r_2 \\cdot \\delta v_{ij})^2}

        where :math:`\\delta u = \\min(|u_i - u_j|,\\, 1 - |u_i - u_j|)` and
        analogously for :math:`\\delta v`.

        Parameters
        ----------
        y : NDArray[np.float64]
            Input array of shape (n_samples, 2), assumed normalized to [0, 1].

        Returns
        -------
        NDArray[np.float64]
            Pairwise geodesic distance matrix of shape (n_samples, n_samples).
        """
        u = y[:, 0]
        v = y[:, 1]

        r1, r2 = self._radii

        u1 = u.reshape(-1, 1)
        u2 = u.reshape(1, -1)
        v1 = v.reshape(-1, 1)
        v2 = v.reshape(1, -1)

        diff_u = np.abs(u1 - u2)
        diff_v = np.abs(v1 - v2)

        circ_diff_u = np.minimum(diff_u, 1.0 - diff_u)
        circ_diff_v = np.minimum(diff_v, 1.0 - diff_v)

        dist_sq = (r1 * circ_diff_u) ** 2 + (r2 * circ_diff_v) ** 2

        result: NDArray[np.float64] = np.sqrt(dist_sq)
        return result

y_ndim property

y_ndim: int

int: Expected input dimensionality (2).

radii property

radii: tuple[float, float]

Tuple of (float, float): Scaling weights for the two cyclic dimensions.

normalize_labels property

normalize_labels: bool

bool: Whether labels are normalized to the unit square.


Discrete Shapes

ClusterShape

smds.shapes.discrete_shapes.cluster.ClusterShape

Bases: BaseShape

Compute ideal distances for categorical data (0 for same, 1 for different).

This shape models data where the only meaningful distinction is category membership. The ideal distance is defined as 0 for points within the same category and 1 for points in different categories.

Parameters:

Name Type Description Default
normalize_labels bool

Whether to normalize labels using the base class logic. Default is False.

False

Attributes:

Name Type Description
y_ndim int

Dimensionality of input labels (1). Expects a 1D array of category labels.

Source code in smds/shapes/discrete_shapes/cluster.py
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
class ClusterShape(BaseShape):
    """
    Compute ideal distances for categorical data (0 for same, 1 for different).

    This shape models data where the only meaningful distinction is category
    membership. The ideal distance is defined as 0 for points within the same
    category and 1 for points in different categories.

    Parameters
    ----------
    normalize_labels : bool, optional
        Whether to normalize labels using the base class logic. Default is False.

    Attributes
    ----------
    y_ndim : int
        Dimensionality of input labels (1). Expects a 1D array of category labels.
    """

    y_ndim = 1

    @property
    def normalize_labels(self) -> bool:
        """bool: Whether input labels are normalized."""
        return self._normalize_labels

    def __init__(self, normalize_labels: bool = False):
        self._normalize_labels = normalize_labels

    def _compute_distances(self, y: NDArray[np.float64]) -> NDArray[np.float64]:
        """
        Compute the binary pairwise distance matrix.

        Parameters
        ----------
        y : NDArray[np.float64]
            Input 1D array of categorical labels.

        Returns
        -------
        NDArray[np.float64]
            A pairwise distance matrix where \\(D_{ij} = 0\\) if \\(y_i = y_j\\) and
            \\(D_{ij} = 1\\) otherwise.
        """
        distance_matrix: NDArray[np.float64] = (y[:, None] != y[None, :]).astype(float)

        return distance_matrix

normalize_labels property

normalize_labels: bool

bool: Whether input labels are normalized.

ChainShape

smds.shapes.discrete_shapes.chain.ChainShape

Bases: BaseShape

Compute sparse cyclic distances where non-neighbors are disconnected.

This shape models a closed loop sequence. Unlike DiscreteCircularShape, which computes the full distance matrix, this shape enforces locality: points separated by a distance greater than or equal to threshold are marked as disconnected (distance = -1.0).

Parameters:

Name Type Description Default
threshold float

The distance cutoff for defining neighbors. Pairs with a cyclic distance less than this value are connected. Default is 2.0 (connects adjacent integers with distance 1).

2.0
normalize_labels bool

Whether to normalize labels using the base class logic. Default is False.

False

Attributes:

Name Type Description
y_ndim int

Dimensionality of input labels (1). Expects ordered sequential data.

Source code in smds/shapes/discrete_shapes/chain.py
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
class ChainShape(BaseShape):
    """
    Compute sparse cyclic distances where non-neighbors are disconnected.

    This shape models a closed loop sequence. Unlike `DiscreteCircularShape`,
    which computes the full distance matrix, this shape enforces locality:
    points separated by a distance greater than or equal to `threshold` are
    marked as disconnected (distance = -1.0).

    Parameters
    ----------
    threshold : float, optional
        The distance cutoff for defining neighbors. Pairs with a cyclic distance
        less than this value are connected. Default is 2.0 (connects adjacent
        integers with distance 1).
    normalize_labels : bool, optional
        Whether to normalize labels using the base class logic. Default is False.

    Attributes
    ----------
    y_ndim : int
        Dimensionality of input labels (1). Expects ordered sequential data.
    """

    y_ndim = 1

    @property
    def normalize_labels(self) -> bool:
        """bool: Whether input labels are normalized."""
        return self._normalize_labels

    def __init__(self, threshold: float = 2.0, normalize_labels: bool = False):
        if threshold <= 0:
            raise ValueError("threshold must be positive.")
        self.threshold = threshold
        self._normalize_labels = normalize_labels

    def _compute_distances(self, y: NDArray[np.float64]) -> NDArray[np.float64]:
        """
        Compute the thresholded cyclic distance matrix.

        Calculates the shortest ring distance \\(d_{cycle}\\). If \\(d_{cycle} < \\text{threshold}\\),
        the distance is preserved; otherwise, it is set to -1.0 to indicate no connection.

        Parameters
        ----------
        y : NDArray[np.float64]
            Input 1D array of ordered labels.

        Returns
        -------
        NDArray[np.float64]
            A pairwise distance matrix where disconnected pairs have value -1.0.
        """
        cycle_length = np.max(y) + 1

        direct_dist = np.abs(y[:, None] - y[None, :])
        wrap_around_dist = cycle_length - direct_dist
        base_distances = np.minimum(direct_dist, wrap_around_dist)

        distance_matrix = np.where(base_distances < self.threshold, base_distances, -1.0)

        return distance_matrix

normalize_labels property

normalize_labels: bool

bool: Whether input labels are normalized.

DiscreteCircularShape

smds.shapes.discrete_shapes.discrete_circular.DiscreteCircularShape

Bases: BaseShape

Compute distances for ordered, cyclical data (e.g., months, hours).

This shape models features with a fixed number of ordered steps that wrap around (periodic boundary conditions). The ideal geometry forms a ring or regular polygon where adjacent integer categories are equidistant.

Parameters:

Name Type Description Default
num_points int

The total cycle length (modulus). For example, 12 for months or 24 for hours. If None, it is inferred as max(y) + 1.

None
normalize_labels bool

Whether to normalize labels using the base class logic. Default is False, as discrete shapes usually rely on raw integer steps.

False

Attributes:

Name Type Description
y_ndim int

Dimensionality of input labels (1). Expects 1D array of discrete steps.

Source code in smds/shapes/discrete_shapes/discrete_circular.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
class DiscreteCircularShape(BaseShape):
    """
    Compute distances for ordered, cyclical data (e.g., months, hours).

    This shape models features with a fixed number of ordered steps that wrap
    around (periodic boundary conditions). The ideal geometry forms a ring or
    regular polygon where adjacent integer categories are equidistant.

    Parameters
    ----------
    num_points : int, optional
        The total cycle length (modulus). For example, 12 for months or 24 for hours.
        If None, it is inferred as `max(y) + 1`.
    normalize_labels : bool, optional
        Whether to normalize labels using the base class logic. Default is False,
        as discrete shapes usually rely on raw integer steps.

    Attributes
    ----------
    y_ndim : int
        Dimensionality of input labels (1). Expects 1D array of discrete steps.
    """

    y_ndim = 1
    # NOTE: This still enforces float64 as per the BaseShape contract.
    # For a "discrete" shape, one might expect integers.

    @property
    def normalize_labels(self) -> bool:
        """bool: Whether input labels are normalized."""
        return self._normalize_labels

    def __init__(self, num_points: Optional[int] = None, normalize_labels: bool = False) -> None:
        if num_points is not None and num_points <= 0:
            raise ValueError("num_points must be a positive integer.")
        self.num_points = num_points
        self._normalize_labels = normalize_labels

    def _compute_distances(self, y: NDArray[np.float64]) -> NDArray[np.float64]:
        r"""
        Compute the shortest ring distance (circular arc length) between points.

        Calculates the minimum distance along the cycle:
        \[ D_{ij} = \min(|y_i - y_j|, C - |y_i - y_j|) \]
        where \(C\) is the cycle length.

        Parameters
        ----------
        y : NDArray[np.float64]
            Input 1D array of labels representing steps on the circle.

        Returns
        -------
        NDArray[np.float64]
            Pairwise distance matrix representing the shortest path on the ring.
        """
        # Determine cycle length: Use self.num_points if available, else infer.
        if self.num_points is not None:
            cycle_length = float(self.num_points)
        else:
            cycle_length = np.max(y) + 1.0

        # Direct absolute difference
        direct_dist = np.abs(y[:, None] - y[None, :])

        # Reduce modulo cycle_length to handle labels outside [0, cycle_length-1]
        direct_dist = np.mod(direct_dist, cycle_length)

        # Wrap-around difference
        wrap_around_dist = cycle_length - direct_dist

        # Shortest path on the ring
        distance_matrix: NDArray[np.float64] = np.minimum(direct_dist, wrap_around_dist)

        return distance_matrix

normalize_labels property

normalize_labels: bool

bool: Whether input labels are normalized.

HierarchicalShape

smds.shapes.discrete_shapes.hierarchical.HierarchicalShape

Bases: BaseShape

Compute distances based on hierarchical (tree-structured) categorical data.

This shape models data organized in levels (e.g., Country > State > City). The distance between two points is determined by the specific level at which they first diverge. Higher levels (earlier indices) typically represent larger conceptual distances.

Parameters:

Name Type Description Default
level_distances NDArray[float64]

An array of distance penalties corresponding to each level of the hierarchy. level_distances[0] is the distance applied if points differ at the root level (column 0). level_distances[i] is used if points match up to level i-1 but differ at level i.

required
normalize_labels bool

Whether to normalize labels using the base class logic. Default is False, as hierarchical labels are typically discrete categories.

False

Attributes:

Name Type Description
y_ndim int

Dimensionality of input labels (2). Expects (n_samples, n_levels).

level_distances NDArray[float64]

The array of distances converted from the input array.

Source code in smds/shapes/discrete_shapes/hierarchical.py
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
class HierarchicalShape(BaseShape):
    """
    Compute distances based on hierarchical (tree-structured) categorical data.

    This shape models data organized in levels (e.g., Country > State > City).
    The distance between two points is determined by the specific level at which
    they first diverge. Higher levels (earlier indices) typically represent
    larger conceptual distances.



    Parameters
    ----------
    level_distances : NDArray[np.float64]
        An array of distance penalties corresponding to each level of the hierarchy.
        `level_distances[0]` is the distance applied if points differ at the
        root level (column 0). `level_distances[i]` is used if points match
        up to level `i-1` but differ at level `i`.
    normalize_labels : bool, optional
        Whether to normalize labels using the base class logic. Default is False,
        as hierarchical labels are typically discrete categories.

    Attributes
    ----------
    y_ndim : int
        Dimensionality of input labels (2). Expects (n_samples, n_levels).
    level_distances : NDArray[np.float64]
        The array of distances converted from the input array.
    """

    y_ndim = 2

    @property
    def normalize_labels(self) -> bool:
        """bool: Whether input labels are normalized."""
        return self._normalize_labels

    def __init__(self, level_distances: NDArray[np.float64], normalize_labels: bool = False) -> None:
        if level_distances.size == 0:
            raise ValueError("level_distances cannot be empty.")
        if np.any(level_distances < 0):
            raise ValueError("All level_distances must be non-negative.")
        self.level_distances = level_distances
        self._normalize_labels = normalize_labels

    def _validate_input(self, y: NDArray[np.float64]) -> NDArray[np.float64]:
        """
        Validate that input has 2 dimensions and matches the configured levels.

        Parameters
        ----------
        y : NDArray[np.float64]
            Input array of hierarchical labels.

        Returns
        -------
        NDArray[np.float64]
            The validated and potentially cast input array.

        Raises
        ------
        ValueError
            If `y` is empty, not 2D, or the number of columns (levels) does not
            match the length of `level_distances`.
        """
        y_proc: NDArray[np.float64] = np.asarray(y, dtype=np.float64)

        if y_proc.size == 0:
            raise ValueError("Input 'y' cannot be empty.")

        if y_proc.ndim != 2:
            raise ValueError(
                f"Input 'y' must be 2-dimensional (n_samples, n_levels), "
                f"but got shape {y_proc.shape} with {y_proc.ndim} dimensions."
            )

        expected_cols = len(self.level_distances)
        if y_proc.shape[1] != expected_cols:
            raise ValueError(
                f"Input 'y' must have {expected_cols} columns (matching level_distances length), "
                f"but got {y_proc.shape[1]} columns."
            )

        return y_proc

    def _compute_distances(self, y: NDArray[np.float64]) -> NDArray[np.float64]:
        """
        Compute the pairwise distance matrix based on the first diverging level.

        Identifies the index of the first column where \\(y_i\\) and \\(y_j\\) differ.
        The distance is then set to the value in `level_distances` at that index.
        If the rows are identical, the distance is 0.0.

        Parameters
        ----------
        y : NDArray[np.float64]
            A 2D array of shape (n_samples, n_levels).

        Returns
        -------
        NDArray[np.float64]
            A pairwise distance matrix encoding the hierarchical separation.
        """
        differences = y[:, None, :] != y[None, :, :]

        # np.argmax returns the first index of True (the first difference)
        first_diff_level = np.argmax(differences, axis=2)
        has_difference = np.any(differences, axis=2)

        # Map indices to distances; identical rows (no difference) get 0.0
        distance_matrix = np.where(has_difference, self.level_distances[first_diff_level], 0.0)

        return distance_matrix

normalize_labels property

normalize_labels: bool

bool: Whether input labels are normalized.

GraphGeodesicShape

smds.shapes.discrete_shapes.graph_geodesic.GraphGeodesicShape

Bases: BaseShape

Approximate manifold distances via a k-nearest-neighbors geodesic graph.

Constructs a KNN graph over the input coordinates and computes the shortest path between all pairs (Isomap approach). This lets the model respect the intrinsic geometry of curved manifolds without knowing their equation.

Parameters:

Name Type Description Default
n_neighbors int

Number of nearest neighbors to connect each point to. Default is 5. Too small a value may leave the graph disconnected; too large a value may introduce shortcuts that distort the geodesic distances.

5
normalize_labels bool

Whether to normalize labels using the base class logic. Default is True.

True

Attributes:

Name Type Description
y_ndim int

Dimensionality of input labels (2). Expects (n_samples, n_features).

Source code in smds/shapes/discrete_shapes/graph_geodesic.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
class GraphGeodesicShape(BaseShape):
    """
    Approximate manifold distances via a k-nearest-neighbors geodesic graph.

    Constructs a KNN graph over the input coordinates and computes the shortest
    path between all pairs (Isomap approach). This lets the model respect the
    intrinsic geometry of curved manifolds without knowing their equation.

    Parameters
    ----------
    n_neighbors : int, optional
        Number of nearest neighbors to connect each point to. Default is 5.
        Too small a value may leave the graph disconnected; too large a value
        may introduce shortcuts that distort the geodesic distances.
    normalize_labels : bool, optional
        Whether to normalize labels using the base class logic. Default is True.

    Attributes
    ----------
    y_ndim : int
        Dimensionality of input labels (2). Expects (n_samples, n_features).
    """

    def __init__(self, n_neighbors: int = 5, normalize_labels: bool = True):
        self.n_neighbors = n_neighbors
        self._normalize_labels_flag = normalize_labels

    @property
    def y_ndim(self) -> int:
        """int: Dimensionality of the input labels (2)."""
        return 2

    @property
    def normalize_labels(self) -> bool:
        """bool: Whether to normalize the input labels."""
        return self._normalize_labels_flag

    def _validate_input(self, y: NDArray[np.float64]) -> NDArray[np.float64]:
        """
        Validate and coerce input to a 2D float64 array.

        Parameters
        ----------
        y : NDArray[np.float64]
            Input array of shape (n_samples,) or (n_samples, n_features).

        Returns
        -------
        NDArray[np.float64]
            Validated 2D array of shape (n_samples, n_features).

        Raises
        ------
        ValueError
            If ``y`` has more than 2 dimensions after reshaping.
        """
        y_proc = np.asarray(y, dtype=np.float64)
        if y_proc.ndim == 1:
            y_proc = y_proc.reshape(-1, 1)
        if y_proc.ndim != 2:
            raise ValueError(f"GraphGeodesicShape expects 2D input (n_samples, n_features), got {y_proc.ndim}D.")
        return y_proc

    def _compute_distances(self, y: NDArray[np.float64]) -> NDArray[np.float64]:
        """
        Build a KNN graph and return all-pairs shortest-path distances.

        Parameters
        ----------
        y : NDArray[np.float64]
            Input array of shape (n_samples, n_features).

        Returns
        -------
        NDArray[np.float64]
            Pairwise geodesic distance matrix of shape (n_samples, n_samples).
        """
        y_proc = np.asarray(y, dtype=np.float64)
        n_samples = y_proc.shape[0]

        effective_k = min(self.n_neighbors, n_samples - 1)
        if effective_k < 1:
            effective_k = 1

        graph = kneighbors_graph(y_proc, n_neighbors=effective_k, mode="distance", include_self=False)

        dist_matrix = shortest_path(csgraph=graph, method="auto", directed=False)

        result: NDArray[np.float64] = np.asarray(dist_matrix, dtype=np.float64)
        return result

y_ndim property

y_ndim: int

int: Dimensionality of the input labels (2).

normalize_labels property

normalize_labels: bool

bool: Whether to normalize the input labels.

PolytopeShape

smds.shapes.discrete_shapes.polytope.PolytopeShape

Bases: BaseShape

Arrange cluster centroids at maximally separated vertices of a unit polytope.

Places n_clusters distinct points on the surface of an n_dim-dimensional unit sphere using iterative repulsion, then maps each input point to its cluster centroid and computes Euclidean distances between centroids.

Parameters:

Name Type Description Default
n_dim int

Number of dimensions of the embedding sphere. Default is 3.

3
n_iter int

Number of repulsion iterations. Default is 500.

500
lr float

Step size for the repulsion update. Default is 0.1.

0.1
seed int

Random seed for reproducibility. Default is None.

None
normalize_labels bool

Whether to normalize labels using the base class logic. Default is False.

False

Attributes:

Name Type Description
y_ndim int

Dimensionality of input labels (1). Expects a 1D array of cluster labels.

Source code in smds/shapes/discrete_shapes/polytope.py
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
class PolytopeShape(BaseShape):
    """
    Arrange cluster centroids at maximally separated vertices of a unit polytope.

    Places ``n_clusters`` distinct points on the surface of an ``n_dim``-dimensional
    unit sphere using iterative repulsion, then maps each input point to its
    cluster centroid and computes Euclidean distances between centroids.

    Parameters
    ----------
    n_dim : int, optional
        Number of dimensions of the embedding sphere. Default is 3.
    n_iter : int, optional
        Number of repulsion iterations. Default is 500.
    lr : float, optional
        Step size for the repulsion update. Default is 0.1.
    seed : int, optional
        Random seed for reproducibility. Default is None.
    normalize_labels : bool, optional
        Whether to normalize labels using the base class logic. Default is False.

    Attributes
    ----------
    y_ndim : int
        Dimensionality of input labels (1). Expects a 1D array of cluster labels.
    """

    def __init__(
        self,
        n_dim: int = 3,
        n_iter: int = 500,
        lr: float = 0.1,
        seed: int | None = None,
        normalize_labels: bool = False,
    ):
        self.n_dim = n_dim
        self.n_iter = n_iter
        self.lr = lr
        self.seed = seed
        self._normalize_labels_flag = normalize_labels

    @property
    def y_ndim(self) -> int:
        """Dimensionality of the input labels."""
        return 1

    @property
    def normalize_labels(self) -> bool:
        """Whether to normalize the input labels."""
        return self._normalize_labels_flag

    def _validate_input(self, y: NDArray[np.float64]) -> NDArray[np.float64]:
        y_proc = np.asarray(y, dtype=np.float64).squeeze()
        if y_proc.size == 0:
            raise ValueError("Input 'y' cannot be empty.")
        if y_proc.ndim == 0:
            y_proc = y_proc.reshape(1)
        elif y_proc.ndim > 1:
            raise ValueError("PolytopeShape expects 1D cluster labels.")
        return y_proc

    def _compute_distances(self, y: NDArray[np.float64]) -> NDArray[np.float64]:
        """
        Place cluster centroids on a unit sphere via repulsion and return pairwise distances.

        Parameters
        ----------
        y : NDArray[np.float64]
            Input 1D array of cluster labels.

        Returns
        -------
        NDArray[np.float64]
            Pairwise Euclidean distance matrix between the repulsion-optimized centroids.
        """
        rng = np.random.default_rng(self.seed)
        labels, inverse_indices = np.unique(y, return_inverse=True)
        C = len(labels)

        V = rng.normal(size=(self.n_dim, C))
        V /= np.linalg.norm(V, axis=0, keepdims=True)

        for _ in range(self.n_iter):
            diff = V[:, :, None] - V[:, None, :]

            dist_sq = np.sum(diff**2, axis=0) + 1e-8
            inv_dist = 1.0 / dist_sq

            np.fill_diagonal(inv_dist, 0)

            forces = np.sum(diff * inv_dist[None, :, :], axis=2)
            V += self.lr * forces
            V /= np.linalg.norm(V, axis=0, keepdims=True)

        y_vertices = V[:, inverse_indices].T

        diff_y = y_vertices[:, None, :] - y_vertices[None, :, :]
        dist_matrix: NDArray[np.float64] = np.linalg.norm(diff_y, axis=-1)

        return dist_matrix

y_ndim property

y_ndim: int

Dimensionality of the input labels.

normalize_labels property

normalize_labels: bool

Whether to normalize the input labels.


Spatial Shapes

SphericalShape

smds.shapes.spatial_shapes.spherical.SphericalShape

Bases: BaseShape

Compute Euclidean (chord) distances between points projected onto a sphere.

Unlike GeodesicShape which measures distance along the surface, this shape measures the straight-line distance through the sphere's volume.

Parameters:

Name Type Description Default
radius float

Radius of the sphere. Default is 1.0.

1.0
normalize_labels bool

Whether to normalize labels using the base class logic. Default is False.

False

Attributes:

Name Type Description
y_ndim int

Dimensionality of input labels (2). Expects (n_samples, 2) for lat/lon.

Source code in smds/shapes/spatial_shapes/spherical.py
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
class SphericalShape(BaseShape):
    """
    Compute Euclidean (chord) distances between points projected onto a sphere.

    Unlike GeodesicShape which measures distance along the surface, this shape
    measures the straight-line distance through the sphere's volume.

    Parameters
    ----------
    radius : float, optional
        Radius of the sphere. Default is 1.0.
    normalize_labels : bool, optional
        Whether to normalize labels using the base class logic. Default is False.

    Attributes
    ----------
    y_ndim : int
        Dimensionality of input labels (2). Expects (n_samples, 2) for lat/lon.
    """

    y_ndim = 2

    @property
    def normalize_labels(self) -> bool:
        """bool: Whether input labels are normalized."""
        return self._normalize_labels

    def __init__(self, radius: float = 1.0, normalize_labels: bool = False):
        self.radius = radius
        self._normalize_labels = normalize_labels

    def _validate_input(self, y: NDArray[np.float64]) -> NDArray[np.float64]:
        """
        Validate input is 2D with shape (n_samples, 2).

        Parameters
        ----------
        y : NDArray[np.float64]
            Input coordinates (latitude, longitude) in degrees.

        Returns
        -------
        NDArray[np.float64]
            Validated input array.

        Raises
        ------
        ValueError
            If input is empty or shape is not (n_samples, 2).
        """
        y_proc: NDArray[np.float64] = np.asarray(y, dtype=np.float64)

        if y_proc.size == 0:
            raise ValueError("Input 'y' cannot be empty.")

        if y_proc.ndim != 2 or y_proc.shape[1] != 2:
            raise ValueError(
                f"Input 'y' must be 2-dimensional (n_samples, 2), "
                f"but got shape {y_proc.shape} with {y_proc.ndim} dimensions."
            )

        return y_proc

    def _compute_distances(self, y: NDArray[np.float64]) -> NDArray[np.float64]:
        """
        Convert spherical coordinates to 3D Cartesian points and compute Euclidean norms.

        Maps input (lat, lon) to \\((x, y, z)\\) using:
        \\[ x = r \\cos(lat) \\cos(lon) \\]
        \\[ y = r \\cos(lat) \\sin(lon) \\]
        \\[ z = r \\sin(lat) \\]

        Parameters
        ----------
        y : NDArray[np.float64]
            Input array of shape (n_samples, 2) containing latitude and longitude
            in degrees.

        Returns
        -------
        NDArray[np.float64]
            Pairwise Euclidean distance matrix (chord lengths).
        """
        lat = np.radians(y[:, 0])
        lon = np.radians(y[:, 1])

        coords = np.stack(
            [
                self.radius * np.cos(lat) * np.cos(lon),  # x
                self.radius * np.cos(lat) * np.sin(lon),  # y
                self.radius * np.sin(lat),  # z
            ],
            axis=1,
        )

        diffs = coords[:, np.newaxis, :] - coords[np.newaxis, :, :]
        distance: NDArray[np.float64] = np.linalg.norm(diffs, axis=2)
        return distance

normalize_labels property

normalize_labels: bool

bool: Whether input labels are normalized.

CylindricalShape

smds.shapes.spatial_shapes.cylindrical.CylindricalShape

Bases: BaseShape

Compute Euclidean distances between points mapped onto a cylinder.

This class maps input coordinates to a 3D cylindrical surface. One dimension is treated as the height (linear) and the other as the angle (circular) around a cylinder of fixed radius.

Parameters:

Name Type Description Default
radius float

The radius of the cylinder. Default is 1.0.

1.0
normalize_labels bool

Whether to normalize labels using the base class logic. Default is False.

False

Attributes:

Name Type Description
y_ndim int

Dimensionality of input labels (2). Expects (n_samples, 2).

Source code in smds/shapes/spatial_shapes/cylindrical.py
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
class CylindricalShape(BaseShape):
    """
    Compute Euclidean distances between points mapped onto a cylinder.

    This class maps input coordinates to a 3D cylindrical surface. One dimension
    is treated as the height (linear) and the other as the angle (circular) around
    a cylinder of fixed radius.

    Parameters
    ----------
    radius : float, optional
        The radius of the cylinder. Default is 1.0.
    normalize_labels : bool, optional
        Whether to normalize labels using the base class logic. Default is False.

    Attributes
    ----------
    y_ndim : int
        Dimensionality of input labels (2). Expects (n_samples, 2).
    """

    y_ndim = 2

    @property
    def normalize_labels(self) -> bool:
        """bool: Whether input labels are normalized."""
        return self._normalize_labels

    def __init__(self, radius: float = 1.0, normalize_labels: bool = False):
        self.radius = radius
        self._normalize_labels = normalize_labels

    def _validate_input(self, y: NDArray[np.float64]) -> NDArray[np.float64]:
        """
        Validate input is 2D with shape (n_samples, 2).

        Parameters
        ----------
        y : NDArray[np.float64]
            Input coordinates (e.g., latitude/height, longitude/angle).

        Returns
        -------
        NDArray[np.float64]
            Validated input array.

        Raises
        ------
        ValueError
            If input is empty or shape is not (n_samples, 2).
        """
        y_proc: NDArray[np.float64] = np.asarray(y, dtype=np.float64)

        if y_proc.size == 0:
            raise ValueError("Input 'y' cannot be empty.")

        if y_proc.ndim != 2 or y_proc.shape[1] != 2:
            raise ValueError(
                f"Input 'y' must be 2-dimensional (n_samples, 2), "
                f"but got shape {y_proc.shape} with {y_proc.ndim} dimensions."
            )

        return y_proc

    def _compute_distances(self, y: NDArray[np.float64]) -> NDArray[np.float64]:
        """
        Map inputs to cylindrical coordinates and compute Euclidean norms.

        Maps input (lat, lon) to \\((x, y, z)\\) where:
        - \\(x, y\\) are derived from `lon` (angle) and `radius`.
        - \\(z\\) is derived directly from `lat` (height).

        Parameters
        ----------
        y : NDArray[np.float64]
            Input array of shape (n_samples, 2). By convention, column 0 is
            treated as the vertical component (latitude/height) and column 1
            as the angular component (longitude).

        Returns
        -------
        NDArray[np.float64]
            Pairwise Euclidean distance matrix through 3D space.
        """
        # todo: maybe normalize latitiude to radius?
        lat = np.radians(y[:, 0])  # latitude as height
        lon = np.radians(y[:, 1])  # longitude as angle

        coords = np.stack(
            [
                self.radius * np.cos(lon),
                self.radius * np.sin(lon),
                lat,  # treat lat as height
            ],
            axis=1,
        )

        diffs = coords[:, np.newaxis, :] - coords[np.newaxis, :, :]
        distance: NDArray[np.float64] = np.linalg.norm(diffs, axis=2)
        return distance

normalize_labels property

normalize_labels: bool

bool: Whether input labels are normalized.

GeodesicShape

smds.shapes.spatial_shapes.geodesic.GeodesicShape

Bases: BaseShape

Compute geodesic distances on a spherical manifold (great-circle distance https://en.wikipedia.org/wiki/Great-circle_distance).

Parameters:

Name Type Description Default
radius float

Radius of the sphere. Default is 1.0.

1.0
normalize_labels bool

Whether to normalize labels using the base class logic. Default is False.

False

Attributes:

Name Type Description
y_ndim int

Dimensionality of input labels (2). Expects (n_samples, 2) for lat/lon.

Source code in smds/shapes/spatial_shapes/geodesic.py
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
class GeodesicShape(BaseShape):
    """
    Compute geodesic distances on a spherical manifold (great-circle distance https://en.wikipedia.org/wiki/Great-circle_distance).

    Parameters
    ----------
    radius : float, optional
        Radius of the sphere. Default is 1.0.
    normalize_labels : bool, optional
        Whether to normalize labels using the base class logic. Default is False.

    Attributes
    ----------
    y_ndim : int
        Dimensionality of input labels (2). Expects (n_samples, 2) for lat/lon.
    """

    y_ndim = 2

    @property
    def normalize_labels(self) -> bool:
        """bool: Whether input labels are normalized."""
        return self._normalize_labels

    def __init__(self, radius: float = 1.0, normalize_labels: bool = False):
        self.radius = radius
        self._normalize_labels = normalize_labels

    def _validate_input(self, y: NDArray[np.float64]) -> NDArray[np.float64]:
        """
        Validate input is 2D with shape (n_samples, 2).

        Parameters
        ----------
        y : NDArray[np.float64]
            Input coordinates (latitude, longitude) in degrees.

        Returns
        -------
        NDArray[np.float64]
            Validated input array.

        Raises
        ------
        ValueError
            If input is empty or shape is not (n_samples, 2).
        """
        y_proc: NDArray[np.float64] = np.asarray(y, dtype=np.float64)

        if y_proc.size == 0:
            raise ValueError("Input 'y' cannot be empty.")

        if y_proc.ndim != 2 or y_proc.shape[1] != 2:
            raise ValueError(
                f"Input 'y' must be 2-dimensional (n_samples, 2), "
                f"but got shape {y_proc.shape} with {y_proc.ndim} dimensions."
            )

        return y_proc

    def _compute_distances(self, y: NDArray[np.float64]) -> NDArray[np.float64]:
        """
        Calculate the great-circle distance between points using the Haversine formula.

        Parameters
        ----------
        y : NDArray[np.float64]
            Input array of shape (n_samples, 2) containing latitude and longitude
            in degrees.

        Returns
        -------
        NDArray[np.float64]
            Pairwise distance matrix.
        """
        lat = np.radians(y[:, 0])[:, np.newaxis]
        lon = np.radians(y[:, 1])[:, np.newaxis]

        dlat = lat - lat.T
        dlon = lon - lon.T

        lat1 = lat
        lat2 = lat.T

        a = np.sin(dlat / 2) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon / 2) ** 2
        a = np.clip(a, 0, 1)  # prevent floating-point error
        c = 2 * np.arcsin(np.sqrt(a))
        distance: NDArray[np.float64] = self.radius * c
        return distance

normalize_labels property

normalize_labels: bool

bool: Whether input labels are normalized.