Repository: martinfleis/clustergram

Issue:
Allow manual input
Allow manual input of cluster centers, data and labels to generate clustergram based on unsupported clusterings, like from spopt.

Discussion:
codecov-commenter: # [Codecov](https://codecov.io/gh/martinfleis/clustergram/pull/12?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Martin+Fleischmann) Report
> Merging [#12](https://codecov.io/gh/martinfleis/clustergram/pull/12?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Martin+Fleischmann) (bc354b5) into [main](https://codecov.io/gh/martinfleis/clustergram/commit/35bbf8d8c18d98950a1a93d06e6efcaf84c34e2f?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Martin+Fleischmann) (35bbf8d) will **increase** coverage by `2.21%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/martinfleis/clustergram/pull/12/graphs/tree.svg?width=650&height=150&src=pr&token=4IL16GQR4P&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Martin+Fleischmann)](https://codecov.io/gh/martinfleis/clustergram/pull/12?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Martin+Fleischmann)

```diff
@@            Coverage Diff             @@
##             main      #12      +/-   ##
==========================================
+ Coverage   57.59%   59.81%   +2.21%     
==========================================
  Files           3        3              
  Lines         507      535      +28     
==========================================
+ Hits          292      320      +28     
  Misses        215      215              
```


| [Impacted Files](https://codecov.io/gh/martinfleis/clustergram/pull/12?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Martin+Fleischmann) | Coverage Δ | |
|---|---|---|
| [clustergram/clustergram.py](https://codecov.io/gh/martinfleis/clustergram/pull/12/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Martin+Fleischmann#diff-Y2x1c3RlcmdyYW0vY2x1c3RlcmdyYW0ucHk=) | `79.20% <100.00%> (+2.62%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/martinfleis/clustergram/pull/12?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Martin+Fleischmann).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Martin+Fleischmann)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/martinfleis/clustergram/pull/12?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Martin+Fleischmann). Last update [35bbf8d...bc354b5](https://codecov.io/gh/martinfleis/clustergram/pull/12?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Martin+Fleischmann). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Martin+Fleischmann).


Patch:
diff --git a/README.md b/README.md
index de31fbe..c80f99d 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ Clustergram is a diagram proposed by Matthias Schonlau in his paper *[The cluste
 
 The clustergram was later implemented in R by [Tal Galili](https://www.r-statistics.com/2010/06/clustergram-visualization-and-diagnostics-for-cluster-analysis-r-code/), who also gives a thorough explanation of the concept.
 
-This is a Python translation of Tal's script written for `scikit-learn` and RAPIDS `cuML` implementations of K-Means, Mini Batch K-Means and Gaussian Mixture Model (scikit-learn only) clustering, plus hierarchical/agglomerative clustering using `SciPy`.
+This is a Python translation of Tal's script written for `scikit-learn` and RAPIDS `cuML` implementations of K-Means, Mini Batch K-Means and Gaussian Mixture Model (scikit-learn only) clustering, plus hierarchical/agglomerative clustering using `SciPy`. Alternatively, you can create clustergram using  `from_*` constructors based on alternative clustering algorithms.
 
 ## Getting started
 
@@ -147,6 +147,40 @@ cgram.fit(data)
 cgram.plot()
 ```
 
+## Manual input
+
+Alternatively, you can create clustergram using `from_data` or  `from_centers` methods based on alternative clustering algorithms.
+
+Using `Clustergram.from_data` which creates cluster centers as mean or median values:
+
+```python
+data = numpy.array([[-1, -1, 0, 10], [1, 1, 10, 2], [0, 0, 20, 4]])
+labels = pandas.DataFrame({1: [0, 0, 0], 2: [0, 0, 1], 3: [0, 2, 1]})
+
+cgram = Clustergram.from_data(data, labels)
+cgram.plot()
+```
+
+Using `Clustergram.from_centers` based on explicit cluster centers.:
+
+```python
+labels = pandas.DataFrame({1: [0, 0, 0], 2: [0, 0, 1], 3: [0, 2, 1]})
+centers = {
+            1: np.array([[0, 0]]),
+            2: np.array([[-1, -1], [1, 1]]),
+            3: np.array([[-1, -1], [1, 1], [0, 0]]),
+        }
+cgram = Clustergram.from_centers(centers, labels)
+cgram.plot(pca_weighted=False)
+```
+
+To support PCA weighted plots you also need to pass data:
+
+```python
+cgram = Clustergram.from_centers(centers, labels, data=data)
+cgram.plot()
+```
+
 ## Partial plot
 
 `Clustergram.plot()` can also plot only a part of the diagram, if you want to focus on a limited range of `k`.
diff --git a/clustergram/clustergram.py b/clustergram/clustergram.py
index d849574..04f278c 100644
--- a/clustergram/clustergram.py
+++ b/clustergram/clustergram.py
@@ -30,6 +30,9 @@ class Clustergram:
     are optional dependencies but you will need at least one of them to
     generate clustergram.
 
+    Alternatively, you can create clustergram using ``from_data`` or
+    ``from_centers`` methods based on alternative clustering algorithms.
+
     Parameters
     ----------
     k_range : iterable (default None)
@@ -63,8 +66,7 @@ class Clustergram:
         Additional arguments passed to the model (e.g. ``KMeans``),
         e.g. ``random_state``. Pass ``linkage`` to specify linkage method in
         case of hierarchical clustering (e.g. ``linkage='ward'``). See the
-        documentation of scipy for details:
-        https://docs.scipy.org/doc/scipy/reference/generated/scipy.cluster.hierarchy.linkage.html#scipy.cluster.hierarchy.linkage
+        documentation of scipy for details.
 
 
     Attributes
@@ -130,7 +132,8 @@ class Clustergram:
             self.backend = backend
 
         supported = ["kmeans", "gmm", "minibatchkmeans", "hierarchical"]
-        if method not in supported:
+        class_methods = ["from_centers", "from_data"]
+        if method not in supported and method not in class_methods:
             raise ValueError(
                 f"'{method}' is not a supported method. "
                 f"Only {supported} are supported now."
@@ -314,6 +317,143 @@ class Clustergram:
             self.labels[i] = lab - 1
             self.cluster_centers[i] = data.groupby(lab).mean().values
 
+    @classmethod
+    def from_centers(cls, cluster_centers, labels, data=None):
+        """Create clustergram based on cluster centers dictionary and labels DataFrame
+
+        Parameters
+        ----------
+
+        cluster_centers : dict
+            dictionary of cluster centers with keys encoding the number of clusters
+            and values being ``M``x````N`` arrays where ``M`` == key and ``N`` ==
+            number of variables in the original dataset.
+            Entries should be ordered based on keys.
+        labels : pandas.DataFrame
+            DataFrame with columns representing cluster labels and rows representing
+            observations. Columns must be equal to ``cluster_centers`` keys.
+        data : array-like (optional)
+            array used as an input of the clustering algorithm with ``N`` columns.
+            Required for `plot(pca_weighted=True)` plotting option. Otherwise only
+            `plot(pca_weighted=False)` is available.
+
+        Returns
+        -------
+        clustegram.Clustergram
+
+        Notes
+        -----
+        The algortihm uses ``sklearn`` and ``pandas`` to generate clustergram.
+        GPU option is not implemented.
+
+        Examples
+        --------
+        >>> import pandas as pd
+        >>> import numpy as np
+        >>> labels = pd.DataFrame({1: [0, 0, 0], 2: [0, 0, 1], 3: [0, 2, 1]})
+        >>> labels
+           1  2  3
+        0  0  0  0
+        1  0  0  2
+        2  0  1  1
+        >>> centers = {
+        ...             1: np.array([[0, 0]]),
+        ...             2: np.array([[-1, -1], [1, 1]]),
+        ...             3: np.array([[-1, -1], [1, 1], [0, 0]]),
+        ...         }
+        >>> cgram = Clustergram.from_centers(centers, labels)
+        >>> cgram.plot(pca_weighted=False)
+
+        >>> data = np.array([[-1, -1], [1, 1], [0, 0]])
+        >>> cgram = Clustergram.from_centers(centers, labels, data=data)
+        >>> cgram.plot()
+
+        """
+        if not (list(cluster_centers.keys()) == labels.columns).all():
+            raise ValueError("'cluster_centers' keys do not match 'labels' columns.")
+
+        cgram = cls(k_range=list(cluster_centers.keys()), method="from_centers")
+
+        cgram.cluster_centers = cluster_centers
+        cgram.labels = labels
+        cgram.backend = "sklearn"
+
+        if data is not None:
+            cgram.data = data
+
+        return cgram
+
+    @classmethod
+    def from_data(cls, data, labels, method="mean"):
+        """Create clustergram based on data and labels DataFrame
+
+        Cluster centers are created as mean values or median values as a
+        groupby function over data using individual labels.
+
+        Parameters
+        ----------
+
+        data : array-like
+            array used as an input of the clustering algorithm in the ``(M, N)`` shape
+            where ``M`` == number of observations and ``N`` == number of variables
+        labels : pandas.DataFrame
+            DataFrame with columns representing cluster labels and rows representing
+            observations. Columns must be equal to ``cluster_centers`` keys.
+        method : {'mean', 'median'}, default 'mean'
+            Method of computation of cluster centres.
+
+        Returns
+        -------
+        clustegram.Clustergram
+
+        Notes
+        -----
+        The algortihm uses ``sklearn`` and ``pandas`` to generate clustergram.
+        GPU option is not implemented.
+
+        Examples
+        --------
+        >>> import pandas as pd
+        >>> import numpy as np
+        >>> data = np.array([[-1, -1, 0, 10], [1, 1, 10, 2], [0, 0, 20, 4]])
+        >>> data
+        array([[-1, -1,  0, 10],
+               [ 1,  1, 10,  2],
+               [ 0,  0, 20,  4]])
+        >>> labels = pd.DataFrame({1: [0, 0, 0], 2: [0, 0, 1], 3: [0, 2, 1]})
+        >>> labels
+           1  2  3
+        0  0  0  0
+        1  0  0  2
+        2  0  1  1
+        >>> cgram = Clustergram.from_data(data, labels)
+        >>> cgram.plot()
+
+        """
+
+        cgram = cls(k_range=list(labels.columns), method="from_data")
+
+        cgram.cluster_centers = {}
+        cgram.data = data
+
+        if not isinstance(data, pd.DataFrame):
+            data = pd.DataFrame(data)
+
+        for i in cgram.k_range:
+            if method == "mean":
+                cgram.cluster_centers[i] = data.groupby(labels[i]).mean().values
+            elif method == "median":
+                cgram.cluster_centers[i] = data.groupby(labels[i]).median().values
+            else:
+                raise ValueError(
+                    f"'{method}' is not supported. Use 'mean' or 'median'."
+                )
+
+        cgram.labels = labels
+        cgram.backend = "sklearn"
+
+        return cgram
+
     def silhouette_score(self, **kwargs):
         """
         Compute the mean Silhouette Coefficient of all samples.
@@ -332,7 +472,7 @@ class Clustergram:
 
         Returns
         -------
-        self.silhouette : pd.Series
+        silhouette : pd.Series
 
         Notes
         -----
@@ -401,7 +541,7 @@ class Clustergram:
 
         Returns
         -------
-        self.calinski_harabasz : pd.Series
+        calinski_harabasz : pd.Series
 
         Notes
         -----
@@ -469,7 +609,7 @@ class Clustergram:
 
         Returns
         -------
-        self.davies_bouldin : pd.Series
+        davies_bouldin : pd.Series
 
         Notes
         -----
diff --git a/doc/api.rst b/doc/api.rst
index 63d446b..91c46bd 100644
--- a/doc/api.rst
+++ b/doc/api.rst
@@ -10,4 +10,4 @@ clustergram
 .. currentmodule:: clustergram
 
 .. autoclass:: Clustergram
-   :members: fit, plot, silhouette_score, calinski_harabasz_score, davies_bouldin_score
\ No newline at end of file
+   :members: fit, plot, from_data, from_centers, silhouette_score, calinski_harabasz_score, davies_bouldin_score
\ No newline at end of file
diff --git a/doc/conf.py b/doc/conf.py
index 216ce65..ba402c1 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -34,6 +34,7 @@ release = version
 extensions = ["sphinx.ext.autodoc", "numpydoc", "sphinx.ext.autosummary", "myst_parser"]
 
 autosummary_generate = True
+numpydoc_show_class_members = True
 
 # Add any paths that contain templates here, relative to this directory.
 templates_path = ["_templates"]
diff --git a/doc/index.md b/doc/index.md
index 77df37c..39aece2 100644
--- a/doc/index.md
+++ b/doc/index.md
@@ -8,7 +8,8 @@ Clustergram is a diagram proposed by Matthias Schonlau in his paper *[The cluste
 
 The clustergram was later implemented in R by [Tal Galili](https://www.r-statistics.com/2010/06/clustergram-visualization-and-diagnostics-for-cluster-analysis-r-code/), who also gives a thorough explanation of the concept.
 
-This is a Python translation of Tal's script written for `scikit-learn` and RAPIDS `cuML` implementations of K-Means, Mini Batch K-Means and Gaussian Mixture Model (scikit-learn only) clustering, plus hierarchical/agglomerative clustering using `SciPy`.
+This is a Python translation of Tal's script written for `scikit-learn` and RAPIDS `cuML` implementations of K-Means, Mini Batch K-Means and Gaussian Mixture Model (scikit-learn only) clustering, plus hierarchical/agglomerative clustering using `SciPy`. Alternatively, you can create clustergram using  `from_*` constructors based on alternative clustering algorithms.
+
 
 ## Getting started
 
@@ -145,6 +146,40 @@ cgram.fit(data)
 cgram.plot()
 ```
 
+## Manual input
+
+Alternatively, you can create clustergram using `from_data` or  `from_centers` methods based on alternative clustering algorithms.
+
+Using `Clustergram.from_data` which creates cluster centers as mean or median values:
+
+```python
+data = numpy.array([[-1, -1, 0, 10], [1, 1, 10, 2], [0, 0, 20, 4]])
+labels = pandas.DataFrame({1: [0, 0, 0], 2: [0, 0, 1], 3: [0, 2, 1]})
+
+cgram = Clustergram.from_data(data, labels)
+cgram.plot()
+```
+
+Using `Clustergram.from_centers` based on explicit cluster centers.:
+
+```python
+labels = pandas.DataFrame({1: [0, 0, 0], 2: [0, 0, 1], 3: [0, 2, 1]})
+centers = {
+            1: np.array([[0, 0]]),
+            2: np.array([[-1, -1], [1, 1]]),
+            3: np.array([[-1, -1], [1, 1], [0, 0]]),
+        }
+cgram = Clustergram.from_centers(centers, labels)
+cgram.plot(pca_weighted=False)
+```
+
+To support PCA weighted plots you also need to pass data:
+
+```python
+cgram = Clustergram.from_centers(centers, labels, data=data)
+cgram.plot()
+```
+
 ## Partial plot
 
 `Clustergram.plot()` can also plot only a part of the diagram, if you want to focus on a limited range of `k`.


Test patch:
diff --git a/clustergram/test_clustergram.py b/clustergram/test_clustergram.py
index c391e72..ec9d3db 100644
--- a/clustergram/test_clustergram.py
+++ b/clustergram/test_clustergram.py
@@ -589,3 +589,98 @@ def test_davies_bouldin_score_cuml():
             name="davies_bouldin_score",
         ),
     )
+
+
+def test_from_data_mean():
+    data = np.array([[-1, -1, 0, 10], [1, 1, 10, 2], [0, 0, 20, 4]])
+    labels = pd.DataFrame({1: [0, 0, 0], 2: [0, 0, 1], 3: [0, 2, 1]})
+    clustergram = Clustergram.from_data(data, labels)
+
+    assert clustergram.plot_data_pca.empty
+    ax = clustergram.plot(pca_kwargs=dict(random_state=random_state))
+    ax.get_geometry() == (1, 1, 1)
+
+    assert clustergram.plot_data.empty
+    ax = clustergram.plot(pca_weighted=False)
+    ax.get_geometry() == (1, 1, 1)
+
+    assert clustergram.plot_data_pca.mean().mean() == pytest.approx(
+        -7.820673888000655, rel=1e-15
+    )
+    assert clustergram.plot_data.mean().mean() == pytest.approx(
+        3.8333333333333335, rel=1e-15
+    )
+
+
+def test_from_data_median():
+    data = np.array([[-1, -1, 0, 10], [1, 1, 10, 2], [0, 0, 20, 4]])
+    labels = pd.DataFrame({1: [0, 0, 0], 2: [0, 0, 1], 3: [0, 2, 1]})
+    clustergram = Clustergram.from_data(data, labels, method="median")
+
+    assert clustergram.plot_data_pca.empty
+    ax = clustergram.plot(pca_kwargs=dict(random_state=random_state))
+    ax.get_geometry() == (1, 1, 1)
+
+    assert clustergram.plot_data.empty
+    ax = clustergram.plot(pca_weighted=False)
+    ax.get_geometry() == (1, 1, 1)
+
+    assert clustergram.plot_data_pca.mean().mean() == pytest.approx(
+        -7.958519683972767, rel=1e-15
+    )
+    assert clustergram.plot_data.mean().mean() == pytest.approx(
+        3.7222222222222228, rel=1e-15
+    )
+
+
+def test_from_data_nonsense():
+    data = np.array([[-1, -1, 0, 10], [1, 1, 10, 2], [0, 0, 20, 4]])
+    labels = pd.DataFrame({1: [0, 0, 0], 2: [0, 0, 1], 3: [0, 2, 1]})
+    with pytest.raises(ValueError, match="'nonsense' is not supported."):
+        Clustergram.from_data(data, labels, method="nonsense")
+
+
+def test_from_centers():
+    labels = pd.DataFrame({1: [0, 0, 0], 2: [0, 0, 1], 3: [0, 2, 1]})
+    centers = {
+        1: np.array([[0, 0]]),
+        2: np.array([[-1, -1], [1, 1]]),
+        3: np.array([[-1, -1], [1, 1], [0, 0]]),
+    }
+    clustergram = Clustergram.from_centers(centers, labels)
+
+    assert clustergram.plot_data.empty
+    ax = clustergram.plot(pca_weighted=False)
+    ax.get_geometry() == (1, 1, 1)
+
+    assert clustergram.plot_data.mean().mean() == pytest.approx(
+        -0.1111111111111111, rel=1e-15
+    )
+
+    labels = pd.DataFrame({2: [0, 0, 0], 3: [0, 0, 1], 4: [0, 2, 1]})
+    centers = {
+        1: np.array([[0, 0]]),
+        2: np.array([[-1, -1], [1, 1]]),
+        3: np.array([[-1, -1], [1, 1], [0, 0]]),
+    }
+    with pytest.raises(ValueError, match="'cluster_centers' keys do not match"):
+        Clustergram.from_centers(centers, labels)
+
+
+def test_from_centers_data():
+    labels = pd.DataFrame({1: [0, 0, 0], 2: [0, 0, 1], 3: [0, 2, 1]})
+    centers = {
+        1: np.array([[0, 0]]),
+        2: np.array([[-1, -1], [1, 1]]),
+        3: np.array([[-1, -1], [1, 1], [0, 0]]),
+    }
+    data = np.array([[-1, -1], [1, 1], [0, 0]])
+    clustergram = Clustergram.from_centers(centers, labels, data)
+
+    assert clustergram.plot_data_pca.empty
+    ax = clustergram.plot(pca_weighted=True)
+    ax.get_geometry() == (1, 1, 1)
+
+    assert clustergram.plot_data_pca.mean().mean() == pytest.approx(
+        -0.15713484026367722, rel=1e-15
+    )