Repository: aimhubio/aim

Issue:
Pre-binned distribution/histogram 
## 🚀 Feature

A user should be able to create an `aim.Distribution` using histogram data that the user computed. Perhaps by specifying some flag that disables the automatic internal `numpy.histogram`.

### Motivation

Sometimes the exact histogram is known, rather than needing to a sample it from some random source.

### Pitch

```python
counts = [4, 2, 1]
bin_edges = [0, 9, 18, 99]  # or bin_midpoints?
aim.Distribution(counts=counts, bin_edges=bin_edges)
```

### Alternatives

Plotting a typical plot.ly figure instead.

### Additional context

N/A



Discussion:
Hey @YodaEmbedding! 
Thanks for opening the issue; it seems a logical extension of `Distribution` object functionality.
It would be awesome if you contribute the appropriate enhancement to aim 🙌 Would be happy to share some code  pointers.
In terms of the API, does this new `__init__` interface seem reasonable?

```python
class Distribution(CustomObject):
    """Distribution object used to store distribution objects in Aim repository.

    Args:
        data (:obj:): Optional array-like object of data sampled from a distribution.
        hist (:obj:): Optional array-like object representing bin frequency counts.
            Must be specified alongside `bin_edges`. `data` must not be specified.
        bin_edges (:obj:): Optional array-like object representing bin edges.
            Must be specified alongside `hist`. `data` must not be specified.
        bin_count (:obj:`int`, optional): Optional distribution bin count for
            binning `data`. 64 by default, max 512.
    """

    def __init__(self, data=None, *, hist=None, bin_edges=None, bin_count=64):
        super().__init__()

        if not isinstance(bin_count, int):
            raise TypeError('`bin_count` must be an integer.')
        if 1 > bin_count > 512:
            raise ValueError('Supported range for `bin_count` is [1, 512].')
        self.storage['bin_count'] = bin_count

        np_histogram = self._to_np_histogram(data, hist, bin_edges, bin_count)
        self._from_np_histogram(np_histogram)

    def _to_np_histogram(self, data, hist, bin_edges, bin_count):
        if data is None:
            if hist is None or bin_edges is None:
                raise ValueError('Both `hist` and `bin_edges` must be specified.')
            return np.asanyarray(hist), np.asanyarray(bin_edges)
        if hist is not None or bin_edges is not None:
            raise ValueError(
                '`hist` and `bin_edges` may not be specified if `data` is.'
            )
        # convert to np.histogram
        try:
            return np.histogram(data, bins=bin_count)
        except TypeError:
            raise TypeError(
                f'Cannot convert to aim.Distribution. Unsupported type {type(data)}.'
            )
```

Usage:

```python
# Compatible with old interface:
aim.Distribution(sampled_data)

# Supports new usage:
hist, bin_edges = np.histogram(sampled_data)
aim.Distribution(hist=hist, bin_edges=bin_edges)
```

Supporting both `data` and `(hist, bin_edges)` makes it look a bit more complicated than, e.g. deprecating `data` and forcing the user to do `np.histogram` themselves whenever they need it.

Also, is there a reason behind setting `bin_count=512` as the max?
@YodaEmbedding will take a look and get back soon.
Regarding the limitation of 512 bins; this is done mainly for performance considerations. UI component used for showing distribution data has some rendering issues with high number of bins.
@YodaEmbedding, regarding this:

> Supporting both data and (hist, bin_edges) makes it look a bit more complicated than, e.g. deprecating data and forcing the user to do np.histogram themselves whenever they need it.

what about adding named constructor(s) (classmethod to create `Distribution` object) which will initialize and set properties of the newly constructed object? this will allow to"offload" some of the interface form `__init__` method.
Attempt 2 (much cleaner):

```python
class Distribution(CustomObject):
    """Distribution object used to store distribution objects in Aim repository."""

    def __init__(self, hist, bin_edges):
        super().__init__()
        hist = np.asanyarray(hist)
        bin_edges = np.asanyarray(bin_edges)
        self._from_np_histogram(hist, bin_edges)

    @classmethod
    def from_histogram(cls, hist, bin_edges):
        """Create Distribution object from histogram.

        Args:
            hist (:obj:): Array-like object representing bin frequency counts.
                Must be specified alongside `bin_edges`. `data` must not be specified.
            bin_edges (:obj:): Array-like object representing bin edges.
                Must be specified alongside `hist`. `data` must not be specified.
                Max 512 bins allowed.
        """
        return cls(hist, bin_edges)

    @classmethod
    def from_samples(cls, samples, bin_count=64):
        """Create Distribution object from data samples.

        Args:
            samples (:obj:): Array-like object of data sampled from a distribution.
            bin_count (:obj:`int`, optional): Optional distribution bin count for
                binning `samples`. 64 by default, max 512.
        """

        # These checks can perhaps be handled by np.histogram.
        # if not isinstance(bin_count, int):
        #     raise TypeError("`bin_count` must be an integer.")
        # try:
        #     hist, bin_edges = np.histogram(samples, bins=bin_count)
        # except TypeError:
        #     raise TypeError(f"Cannot create histogram from type {type(samples)}.")

        hist, bin_edges = np.histogram(samples, bins=bin_count)
        return cls(hist, bin_edges)

    def _from_np_histogram(self, hist, bin_edges):
        bin_count = len(bin_edges) - 1
        if 1 > bin_count > 512:
            raise ValueError("Supported range for `bin_count` is [1, 512].")

        # Checks unnecessary due to asanyarray.
        # assert isinstance(hist, np.ndarray)
        # assert isinstance(bin_edges, np.ndarray)

        self.storage["data"] = BLOB(data=hist.tobytes())
        self.storage["dtype"] = str(hist.dtype)
        self.storage["bin_count"] = bin_count
        self.storage["range"] = [bin_edges[0].item(), bin_edges[-1].item()]
```
@YodaEmbedding looks good! I think the next step would be to open a PR and test the changes to make sure everything works fine (including UI part)

Resolving patch:
diff --git a/aim/sdk/objects/distribution.py b/aim/sdk/objects/distribution.py
--- a/aim/sdk/objects/distribution.py
+++ b/aim/sdk/objects/distribution.py
@@ -1,7 +1,5 @@
 import numpy as np
 
-from typing import Tuple
-
 from aim.storage.object import CustomObject
 from aim.storage.types import BLOB
 
@@ -11,27 +9,56 @@ class Distribution(CustomObject):
     """Distribution object used to store distribution objects in Aim repository.
 
     Args:
-         distribution (:obj:): array-like object used to construct `aim.Distribution`.
-         bin_count (:obj:`int`, optional): Optional distribution bin count. 64 by default, max 512.
+        samples (:obj:): Array-like object of data sampled from a distribution.
+        bin_count (:obj:`int`, optional): Optional distribution bin count for
+            binning `samples`. 64 by default, max 512.
+        hist (:obj:, optional): Array-like object representing bin frequency counts.
+            Max 512 bins allowed. `samples` must not be specified.
+        bin_range (:obj:`tuple`, optional): Tuple of (start, end) bin range.
     """
 
     AIM_NAME = 'aim.distribution'
 
-    def __init__(self, distribution, bin_count=64):
+    def __init__(self, samples=None, bin_count=64, *, hist=None, bin_range=None):
         super().__init__()
 
-        if not isinstance(bin_count, int):
-            raise TypeError('`bin_count` must be an integer.')
-        if 1 > bin_count > 512:
-            raise ValueError('Supported range for `bin_count` is [1, 512].')
-        self.storage['bin_count'] = bin_count
+        if samples is not None:
+            if hist is not None:
+                raise ValueError("hist should not be specified if samples is specified.")
+            hist, bin_edges = np.histogram(samples, bins=bin_count)
+        elif hist is not None:
+            if bin_range is None:
+                raise ValueError("Please specify bin_range of (start, end) bins.")
+            bin_count = len(hist)
+            bin_edges = np.linspace(bin_range[0], bin_range[-1], num=bin_count + 1)
+        else:
+            raise ValueError("Please specify either samples or hist.")
+
+        hist = np.asanyarray(hist)
+        bin_edges = np.asanyarray(bin_edges)
+        self._from_np_histogram(hist, bin_edges)
+
+    @classmethod
+    def from_histogram(cls, hist, bin_range):
+        """Create Distribution object from histogram.
+
+        Args:
+            hist (:obj:): Array-like object representing bin frequency counts.
+                Max 512 bins allowed.
+            bin_range (:obj:`tuple`, optional): Tuple of (start, end) bin range.
+        """
+        return cls(hist=hist, bin_range=bin_range)
 
-        # convert to np.histogram
-        try:
-            np_histogram = np.histogram(distribution, bins=bin_count)
-        except TypeError:
-            raise TypeError(f'Cannot convert to aim.Distribution. Unsupported type {type(distribution)}.')
-        self._from_np_histogram(np_histogram)
+    @classmethod
+    def from_samples(cls, samples, bin_count=64):
+        """Create Distribution object from data samples.
+
+        Args:
+            samples (:obj:): Array-like object of data sampled from a distribution.
+            bin_count (:obj:`int`, optional): Optional distribution bin count for
+                binning `samples`. 64 by default, max 512.
+        """
+        return cls(samples=samples, bin_count=bin_count)
 
     @property
     def bin_count(self):
@@ -70,7 +97,7 @@ def ranges(self):
             :type: np.ndarray
         """
         assert (len(self.range) == 2)
-        return np.linspace(self.range[0], self.range[1], num=self.bin_count)
+        return np.linspace(self.range[0], self.range[1], num=self.bin_count + 1)
 
     def json(self):
         """Dump distribution metadata to a dict"""
@@ -79,13 +106,15 @@ def json(self):
             'range': self.range,
         }
 
-    def _from_np_histogram(self, np_histogram: Tuple[np.ndarray, np.ndarray]):
-        assert isinstance(np_histogram[0], np.ndarray)
-        assert isinstance(np_histogram[1], np.ndarray)
+    def _from_np_histogram(self, hist: np.ndarray, bin_edges: np.ndarray):
+        bin_count = len(hist)
+        if 1 > bin_count > 512:
+            raise ValueError("Supported range for `bin_count` is [1, 512].")
 
-        self.storage['data'] = BLOB(data=np_histogram[0].tobytes())
-        self.storage['dtype'] = str(np_histogram[0].dtype)
-        self.storage['range'] = [np_histogram[1][0].item(), np_histogram[1][-1].item()]
+        self.storage['data'] = BLOB(data=hist.tobytes())
+        self.storage['dtype'] = str(hist.dtype)
+        self.storage['range'] = [bin_edges[0].item(), bin_edges[-1].item()]
+        self.storage['bin_count'] = bin_count
 
     def to_np_histogram(self):
         """Return `np.histogram` compatible format of the distribution"""


Test patch: