From 9ff2a9e294af5d0a775266ea507ac17e5ad8226d Mon Sep 17 00:00:00 2001 From: "M. A. Huber" <95656104+m-a-huber@users.noreply.github.com> Date: Thu, 20 Apr 2023 16:52:37 +0200 Subject: [PATCH 1/3] Update and rename point_clouds.py to plot_point_clouds.py Add parameters for labelling/coloring and naming points of a point cloud; added parameters for custom marker size and opacity; added parameters to toggle the plot being to scale or not, and printed or not. --- gtda/plotting/plot_point_clouds.py | 244 +++++++++++++++++++++++++++++ gtda/plotting/point_clouds.py | 135 ---------------- 2 files changed, 244 insertions(+), 135 deletions(-) create mode 100644 gtda/plotting/plot_point_clouds.py delete mode 100644 gtda/plotting/point_clouds.py diff --git a/gtda/plotting/plot_point_clouds.py b/gtda/plotting/plot_point_clouds.py new file mode 100644 index 000000000..90ab8219c --- /dev/null +++ b/gtda/plotting/plot_point_clouds.py @@ -0,0 +1,244 @@ +"""Point-cloud–related plotting functions and classes.""" +# License: GNU AGPLv3 + +import numpy as np +import plotly.graph_objs as gobj + +from gtda.utils import validate_params +from gtda.utils.intervals import Interval as Int + + +def plot_point_cloud(point_cloud, + labels=None, + names=None, + dimension=None, + plotly_params=None, + marker_size=5, + opacity=0.8, + to_scale=False, + display_plot=False): + """Plot the first 2 or 3 coordinates of a point cloud. + + Note: this function does not work on 1D arrays. + + Parameters + ---------- + point_cloud : ndarray of shape (n_samples, n_dimensions) + Data points to be represented in a 2D or 3D scatter plot. Only the + first 2 or 3 dimensions will be considered for plotting. + + labels : ndarray of shape (n_samples,) or None, optional, default: ``None`` + Array of labels of data points that, if provided, are used to + color-code the data points. + + names: dict or None, optional, default: ``None`` + Dictionary translating each numeric label into a string representing + its name. Should be of the format {label[int] : name[str]}. + If provided, a legend will be added to the plot. + + dimension : int or None, default: ``None`` + Sets the dimension of the resulting plot. If ``None``, the dimension + will be chosen between 2 and 3 depending on the shape of `point_cloud`. + + plotly_params : dict or None, optional, default: ``None`` + Custom parameters to configure the plotly figure. Allowed keys are + ``"trace"`` and ``"layout"``, and the corresponding values should be + dictionaries containing keyword arguments as would be fed to the + :meth:`update_traces` and :meth:`update_layout` methods of + :class:`plotly.graph_objects.Figure`. + + marker_size : float or None, optional, default: 5 + Sets the size of the markers in the plot. Must be a positive number. + + opacity : float or None, optional, default: 0.8 + Sets the opacity of the markers in the plot. Must be a number between + 0 and 1. + + to_scale : bool or None, optional, default: False + Whether or not to use the same scale across all axes of the plot. + + show_plot: bool or None, optional, default: True + Whether or not to display the plot. + + Returns + ------- + fig : :class:`plotly.graph_objects.Figure` object + Figure representing a point cloud in 2D or 3D. + + """ + + # If no labels provided, just enumerate data points, and record + # if there were user-provided labels to use in `names`. + labels_were_provided = labels is not None + if not labels_were_provided: + labels = np.arange(point_cloud.shape[0]) + + validate_params({"labels": labels}, + {"labels": {"type": (np.ndarray,), + "of": {"type": (np.number,)}}}) + validate_params({"names": names}, + {"names": {"type": (dict, type(None))}}) + validate_params({"dimension": dimension}, + {"dimension": {"type": (int, type(None)), + "in": [2, 3]}}) + validate_params({"marker_size": marker_size}, + {"marker_size": {"type": (float, int), + "in": Int(0, np.inf, closed="neither")}}) + validate_params({"opacity": opacity}, + {"opacity": {"type": (float, int), + "in": Int(0, 1, closed="right")}}) + validate_params({"to_scale": to_scale}, + {"to_scale": {"type": (bool,)}}) + validate_params({"display_plot": display_plot}, + {"display_plot": {"type": (bool,)}}) + + if dimension is None: + dimension = np.min((3, point_cloud.shape[1])) + + if names is not None: + if not labels_were_provided: + raise ValueError("No lables were provided.") + all_labels_have_names = ( + np.array( + [label in names.keys() for label in np.unique(labels)] + ).all() + ) + if not all_labels_have_names: + raise ValueError( + "One or more labels are lacking a corresponding name." + ) + all_names_are_strings = ( + np.array( + [type(value) == str for value in names.values()] + ).all() + ) + if all_names_are_strings: + raise TypeError( + "All values of `names` should be strings." + ) + + # Check consistency between point_cloud and dimension + if point_cloud.shape[1] < dimension: + raise ValueError("Not enough dimensions available in the input point " + "cloud.") + + elif dimension == 2: + layout = { + "width": 600, + "height": 600, + "xaxis1": { + "title": "0th", + "side": "bottom", + "type": "linear", + "ticks": "outside", + "anchor": "x1", + "showline": True, + "zeroline": True, + "showexponent": "all", + "exponentformat": "e" + }, + "yaxis1": { + "title": "1st", + "side": "left", + "type": "linear", + "ticks": "outside", + "anchor": "y1", + "showline": True, + "zeroline": True, + "showexponent": "all", + "exponentformat": "e" + }, + "plot_bgcolor": "white" + } + + fig = gobj.Figure(layout=layout) + fig.update_xaxes(zeroline=True, linewidth=1, linecolor="black", + mirror=False) + fig.update_yaxes(zeroline=True, linewidth=1, linecolor="black", + mirror=False) + + if names is None: + fig.add_trace(gobj.Scatter( + x=point_cloud[:, 0], + y=point_cloud[:, 1], + mode="markers", + marker={"size": marker_size, + "color": labels, + "colorscale": "Viridis", + "opacity": opacity} + )) + else: + for label in np.unique(labels): + fig.add_trace(gobj.Scatter( + x=point_cloud[labels == label][:, 0], + y=point_cloud[labels == label][:, 1], + mode="markers", + name=names[label], + marker={"size": marker_size, + "color": label, + "colorscale": "Viridis", + "opacity": opacity} + )) + if to_scale: + fig.update_yaxes(scaleanchor="x", scaleratio=1) + + elif dimension == 3: + scene = { + "xaxis": { + "title": "0th", + "type": "linear", + "showexponent": "all", + "exponentformat": "e" + }, + "yaxis": { + "title": "1st", + "type": "linear", + "showexponent": "all", + "exponentformat": "e" + }, + "zaxis": { + "title": "2nd", + "type": "linear", + "showexponent": "all", + "exponentformat": "e" + } + } + + fig = gobj.Figure() + fig.update_layout(scene=scene) + + if names is None: + fig.add_trace(gobj.Scatter3d( + x=point_cloud[:, 0], + y=point_cloud[:, 1], + z=point_cloud[:, 2], + mode="markers", + marker={"size": marker_size, + "color": labels, + "colorscale": "Viridis", + "opacity": opacity} + )) + else: + for label in np.unique(labels): + fig.add_trace(gobj.Scatter3d( + x=point_cloud[labels == label][:, 0], + y=point_cloud[labels == label][:, 1], + z=point_cloud[labels == label][:, 2], + mode="markers", + name=names[label], + marker={"size": marker_size, + "color": label, + "colorscale": "Viridis", + "opacity": opacity} + )) + if to_scale: + fig.update_layout(scene_aspectmode='data') + + # Update trace and layout according to user input + if plotly_params: + fig.update_traces(plotly_params.get("trace", None)) + fig.update_layout(plotly_params.get("layout", None)) + + if display_plot: + fig.show() + return fig diff --git a/gtda/plotting/point_clouds.py b/gtda/plotting/point_clouds.py deleted file mode 100644 index bb7eb262e..000000000 --- a/gtda/plotting/point_clouds.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Point-cloud–related plotting functions and classes.""" -# License: GNU AGPLv3 - -import numpy as np -import plotly.graph_objs as gobj - -from ..utils.validation import validate_params - - -def plot_point_cloud(point_cloud, dimension=None, plotly_params=None): - """Plot the first 2 or 3 coordinates of a point cloud. - - Note: this function does not work on 1D arrays. - - Parameters - ---------- - point_cloud : ndarray of shape (n_samples, n_dimensions) - Data points to be represented in a 2D or 3D scatter plot. Only the - first 2 or 3 dimensions will be considered for plotting. - - dimension : int or None, default: ``None`` - Sets the dimension of the resulting plot. If ``None``, the dimension - will be chosen between 2 and 3 depending on the shape of `point_cloud`. - - plotly_params : dict or None, optional, default: ``None`` - Custom parameters to configure the plotly figure. Allowed keys are - ``"trace"`` and ``"layout"``, and the corresponding values should be - dictionaries containing keyword arguments as would be fed to the - :meth:`update_traces` and :meth:`update_layout` methods of - :class:`plotly.graph_objects.Figure`. - - Returns - ------- - fig : :class:`plotly.graph_objects.Figure` object - Figure representing a point cloud in 2D or 3D. - - """ - # TODO: increase the marker size - validate_params({"dimension": dimension}, - {"dimension": {"type": (int, type(None)), "in": [2, 3]}}) - if dimension is None: - dimension = np.min((3, point_cloud.shape[1])) - - # Check consistency between point_cloud and dimension - if point_cloud.shape[1] < dimension: - raise ValueError("Not enough dimensions available in the input point " - "cloud.") - - elif dimension == 2: - layout = { - "width": 600, - "height": 600, - "xaxis1": { - "title": "0th", - "side": "bottom", - "type": "linear", - "ticks": "outside", - "anchor": "x1", - "showline": True, - "zeroline": True, - "showexponent": "all", - "exponentformat": "e" - }, - "yaxis1": { - "title": "1st", - "side": "left", - "type": "linear", - "ticks": "outside", - "anchor": "y1", - "showline": True, - "zeroline": True, - "showexponent": "all", - "exponentformat": "e" - }, - "plot_bgcolor": "white" - } - - fig = gobj.Figure(layout=layout) - fig.update_xaxes(zeroline=True, linewidth=1, linecolor="black", - mirror=False) - fig.update_yaxes(zeroline=True, linewidth=1, linecolor="black", - mirror=False) - - fig.add_trace(gobj.Scatter( - x=point_cloud[:, 0], - y=point_cloud[:, 1], - mode="markers", - marker={"size": 4, - "color": list(range(point_cloud.shape[0])), - "colorscale": "Viridis", - "opacity": 0.8} - )) - - elif dimension == 3: - scene = { - "xaxis": { - "title": "0th", - "type": "linear", - "showexponent": "all", - "exponentformat": "e" - }, - "yaxis": { - "title": "1st", - "type": "linear", - "showexponent": "all", - "exponentformat": "e" - }, - "zaxis": { - "title": "2nd", - "type": "linear", - "showexponent": "all", - "exponentformat": "e" - } - } - - fig = gobj.Figure() - fig.update_layout(scene=scene) - - fig.add_trace(gobj.Scatter3d( - x=point_cloud[:, 0], - y=point_cloud[:, 1], - z=point_cloud[:, 2], - mode="markers", - marker={"size": 4, - "color": list(range(point_cloud.shape[0])), - "colorscale": "Viridis", - "opacity": 0.8} - )) - - # Update trace and layout according to user input - if plotly_params: - fig.update_traces(plotly_params.get("trace", None)) - fig.update_layout(plotly_params.get("layout", None)) - - return fig From 0a45259fac1b3d00f89615bc30951b27ced5cb69 Mon Sep 17 00:00:00 2001 From: "M. A. Huber" <95656104+m-a-huber@users.noreply.github.com> Date: Thu, 20 Apr 2023 16:57:39 +0200 Subject: [PATCH 2/3] Create sample_point_clouds.py Add directory "sampling" containing "sample_point_clouds.py". The file contains functions to sample points from a round sphere, a standardly embedded torus and a circle in the x-y-plane in $\mathbb{R}^3$ uniformly and at random. --- gtda/sampling/sample_point_clouds.py | 163 +++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 gtda/sampling/sample_point_clouds.py diff --git a/gtda/sampling/sample_point_clouds.py b/gtda/sampling/sample_point_clouds.py new file mode 100644 index 000000000..b0519fbe1 --- /dev/null +++ b/gtda/sampling/sample_point_clouds.py @@ -0,0 +1,163 @@ +"""Point-cloud–related plotting functions and classes.""" +# License: GNU AGPLv3 + +import numpy as np +from scipy.optimize import root_scalar + +from gtda.utils import validate_params +from gtda.utils.intervals import Interval + + +def sphere_sampling(n=1000, r=1, noise=0): + """Uniformly and randomly samples points from a round + sphere centered at the origin of 3-space. + Parameters + ---------- + n : int or None, optional, default: 1000 + The number of points to be sampled. + r : float or None, optional, default: 1 + The radius of the sphere to be sampled from. + Must be a positive number. + noise : float or None, optional, default: 0 + The noise of the sampling, which is introduced by + adding Gaussian noise around each data point. + Must be a non-negative number. + Returns + ------- + points : ndarray of shape (n, 3). + NumPy-array containing the sampled points. + """ + + validate_params({"n": n}, + {"n": {"type": (int,), + "in": Interval(0, np.inf, closed="neither")}}) + validate_params({"r": r}, + {"r": {"type": (int, float), + "in": Interval(0, np.inf, closed="neither")}}) + validate_params({"noise": noise}, + {"noise": {"type": (int, float), + "in": Interval(0, np.inf, closed="left")}}) + + def parametrization(theta, phi): + x = r*np.cos(theta)*np.sin(phi) + y = r*np.sin(theta)*np.sin(phi) + z = r*np.cos(phi) + return np.array([x, y, z]) + U_phi_inverse = (lambda y: np.arccos(1-2*y)) + points = np.zeros(shape=(n, 3)) + for i in range(n): + theta = np.random.uniform(low=0, high=2*np.pi) + phi = U_phi_inverse(np.random.uniform()) + pt = parametrization(theta, phi) + if noise: + pt = pt + noise * np.random.randn(3) + points[i] = pt + return points + + +def torus_sampling(n=1000, R=3, r=1, noise=None): + """Uniformly and randomly samples points from a torus + centered at the origin of 3-space and lying in it + horizontally. + Parameters + ---------- + n : int or None, optional, default: 1000 + The number of points to be sampled. + R : float or None, optional, default: 3 + The inner radius of the torus to be sampled from, + that is, the radius of the circle along which the + ``tube`` follows. + Must be a positive number. + r : float or None, optional, default: 1 + The outer radius of the torus to be sampled from, + that is, the radius of the ``tube``. + Must be a positive number. + noise : float or None, optional, default: 0 + The noise of the sampling, which is introduced by + adding Gaussian noise around each data point. + Must be a non-negative number. + Returns + ------- + points : ndarray of shape (n, 3). + NumPy-array containing the sampled points. + """ + + validate_params({"n": n}, + {"n": {"type": (int,), + "in": Interval(0, np.inf, closed="neither")}}) + validate_params({"R": R}, + {"R": {"type": (int, float), + "in": Interval(0, np.inf, closed="neither")}}) + validate_params({"r": r}, + {"r": {"type": (int, float), + "in": Interval(0, np.inf, closed="neither")}}) + validate_params({"noise": noise}, + {"noise": {"type": (int, float), + "in": Interval(0, np.inf, closed="left")}}) + + def parametrization(theta, phi): + x = np.cos(theta)*(R + r*np.cos(phi)) + y = np.sin(theta)*(R + r*np.cos(phi)) + z = r*np.sin(phi) + return np.array([x, y, z]) + U_phi = (lambda x: (0.5/np.pi)*(x + r*np.sin(x)/R)) + + def U_phi_inverse(y): + U_phi_shifted = (lambda x: U_phi(x) - y) + sol = root_scalar(U_phi_shifted, bracket=[0, 2*np.pi]) + return sol.root + points = np.zeros(shape=(n, 3)) + for i in range(n): + theta = np.random.uniform(low=0, high=2*np.pi) + phi = U_phi_inverse(np.random.uniform()) + pt = parametrization(theta, phi) + if noise: + pt = pt + noise * np.random.randn(3) + points[i] = pt + return points + + +def circle_sampling(n=1000, r=1, noise=None): + """Uniformly and randomly samples points from a circle + centered at the origin of 3-space and lying in it + horizontally. + Parameters + ---------- + n : int or None, optional, default: 1000 + The number of points to be sampled. + r : float or None, optional, default: 1 + The radius of the circle to be sampled from. + Must be a positive number. + noise : float or None, optional, default: 0 + The noise of the sampling, which is introduced by + adding Gaussian noise around each data point. + Must be a non-negative number. + Returns + ------- + points : ndarray of shape (n, 3). + NumPy-array containing the sampled points. + """ + + validate_params({"n": n}, + {"n": {"type": (int,), + "in": Interval(0, np.inf, closed="neither")}}) + validate_params({"r": r}, + {"r": {"type": (int, float), + "in": Interval(0, np.inf, closed="neither")}}) + validate_params({"noise": noise}, + {"noise": {"type": (int, float), + "in": Interval(0, np.inf, closed="left")}}) + + def parametrization(theta): + x = r*np.cos(theta) + y = r*np.sin(theta) + z = 0 + return np.array([x, y, z]) + points = np.zeros(shape=(n, 3)) + for i in range(n): + theta = np.random.uniform(low=0, high=2*np.pi) + pt = parametrization(theta) + if noise: + pt = pt + noise * np.random.randn(3) + points[i] = pt + return points From 65d974d75eeebbde3f6140d84ea58ac51ff39432 Mon Sep 17 00:00:00 2001 From: "M. A. Huber" <95656104+m-a-huber@users.noreply.github.com> Date: Thu, 20 Apr 2023 17:10:12 +0200 Subject: [PATCH 3/3] Fix formatting. --- gtda/sampling/sample_point_clouds.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/gtda/sampling/sample_point_clouds.py b/gtda/sampling/sample_point_clouds.py index b0519fbe1..7c0db2665 100644 --- a/gtda/sampling/sample_point_clouds.py +++ b/gtda/sampling/sample_point_clouds.py @@ -11,17 +11,21 @@ def sphere_sampling(n=1000, r=1, noise=0): """Uniformly and randomly samples points from a round sphere centered at the origin of 3-space. + Parameters ---------- n : int or None, optional, default: 1000 The number of points to be sampled. + r : float or None, optional, default: 1 The radius of the sphere to be sampled from. Must be a positive number. + noise : float or None, optional, default: 0 The noise of the sampling, which is introduced by adding Gaussian noise around each data point. Must be a non-negative number. + Returns ------- points : ndarray of shape (n, 3). @@ -59,23 +63,28 @@ def torus_sampling(n=1000, R=3, r=1, noise=None): """Uniformly and randomly samples points from a torus centered at the origin of 3-space and lying in it horizontally. + Parameters ---------- n : int or None, optional, default: 1000 The number of points to be sampled. + R : float or None, optional, default: 3 The inner radius of the torus to be sampled from, that is, the radius of the circle along which the ``tube`` follows. Must be a positive number. + r : float or None, optional, default: 1 The outer radius of the torus to be sampled from, that is, the radius of the ``tube``. Must be a positive number. + noise : float or None, optional, default: 0 The noise of the sampling, which is introduced by adding Gaussian noise around each data point. Must be a non-negative number. + Returns ------- points : ndarray of shape (n, 3). @@ -121,17 +130,21 @@ def circle_sampling(n=1000, r=1, noise=None): """Uniformly and randomly samples points from a circle centered at the origin of 3-space and lying in it horizontally. + Parameters ---------- n : int or None, optional, default: 1000 The number of points to be sampled. + r : float or None, optional, default: 1 The radius of the circle to be sampled from. Must be a positive number. + noise : float or None, optional, default: 0 The noise of the sampling, which is introduced by adding Gaussian noise around each data point. Must be a non-negative number. + Returns ------- points : ndarray of shape (n, 3). @@ -161,3 +174,4 @@ def parametrization(theta): pt = pt + noise * np.random.randn(3) points[i] = pt return points +