Repository: voxel51/fiftyone

Issue:
[FR] Enhance usability of numeric distribution graphs
I have some reactions after using the current state of numeric distribution graphs in the App (see screenshots below for a typical use case).

Note that I'm marking this as `needs-discussion` to avoid wasting too much effort in the current graphing library if we will soon be moving away from it. But, I'm creating this issue nonetheless to document my thoughts on making the graphs as usable as possible.

Enhancements for numeric graphs
- Clarify in the graph that "other" bucket is exclusively `None`-valued fields. The actual aggregation uses `other` as a catch-all for any data outside of the specified bin edges, but we're in control of the bin edges and know that anything outside must be `None` 
- Hide the `other` bin when its count is zero

Integer-field-specific enhancements:
- Suppose the range of the field is `[0, n]`. For reasonably small values of `n`, say `n <= DEFAULT_NUM_BINS`, it would be desirable for the bins to be `[-0.5, 0.5, 1.5, ..., n + 0.5]` with tick labels `0, 1, ..., n` rather than the strange floating point values observed below
- When the range `[0, n]` is too large, I would revert to floating point-type style where where are a fixed number of equal-width bins and a small number of tick labels like ~5 or so

Example graph for an int field defined on 15 out of many samples in a dataset
("other" means `None`-valued, but this is not readily apparent to users from the current display):

<img width="1272" alt="Screen Shot 2021-01-19 at 2 48 15 PM" src="https://user-images.githubusercontent.com/25985824/105086532-20999700-5a67-11eb-8298-4702a2d5f11b.png">

The same graph after applying an `Exists(field, True)` view stage. The tick labels are undesirable because this is an int-valued field with a small range (`[0, 8]` in this case):

<img width="1274" alt="Screen Shot 2021-01-19 at 2 48 49 PM" src="https://user-images.githubusercontent.com/25985824/105086331-e4663680-5a66-11eb-8974-1516f3bf5c29.png">



Discussion:
Addressing all comments shouldn't take long. I can resolve tomorrow.

Resolving patch:
diff --git a/fiftyone/server/main.py b/fiftyone/server/main.py
--- a/fiftyone/server/main.py
+++ b/fiftyone/server/main.py
@@ -8,6 +8,7 @@
 import asyncio
 import argparse
 from collections import defaultdict
+import math
 import os
 import posixpath
 import traceback
@@ -900,29 +901,47 @@ def filter(field):
 
             aggs, fields = _count_values(filter, view)
 
-            hist_aggs, hist_fields = await _numeric_histograms(
+            hist_aggs, hist_fields, ticks = await _numeric_histograms(
                 col, view, view.get_field_schema()
             )
             aggs.extend(hist_aggs)
             fields.extend(hist_fields)
-            results = await _gather_results(col, aggs, fields, view)
+            results = await _gather_results(col, aggs, fields, view, ticks)
 
         results = sorted(results, key=lambda i: i["name"])
         self.write_message({"type": "distributions", "results": results})
 
 
-def _parse_histogram_values(result):
+def _parse_histogram_values(result, field):
     data = sorted(
-        [{"key": k, "count": v} for k, v in zip(result.edges, result.counts)],
+        [
+            {
+                "key": round((k + result.edges[idx + 1]) / 2, 4),
+                "count": v,
+                "edges": (k, result.edges[idx + 1]),
+            }
+            for idx, (k, v) in enumerate(zip(result.edges, result.counts))
+        ],
         key=lambda i: i["key"],
     )
+    if (
+        fos._meets_type(field, fof.IntField)
+        and len(data) == _DEFAULT_NUM_HISTOGRAM_BINS
+    ):
+        for bin_ in data:
+            bin_["edges"] = [math.ceil(e) for e in bin_["edges"]]
+            bin_["key"] = math.ceil(bin_["key"])
+    elif fos._meets_type(field, fof.IntField):
+        for bin_ in data:
+            del bin_["edges"]
+
     if result.other > 0:
-        data.append({"key": "other", "count": result.other})
+        data.append({"key": "None", "count": result.other})
 
     return data
 
 
-def _parse_count_values(result):
+def _parse_count_values(result, field):
     data = sorted(
         [{"key": k, "count": v} for k, v in result.values.items()],
         key=lambda i: i["count"],
@@ -931,7 +950,7 @@ def _parse_count_values(result):
     return data
 
 
-async def _gather_results(col, aggs, fields, view):
+async def _gather_results(col, aggs, fields, view, ticks=None):
     results = []
     response = await view._async_aggregate(col, aggs)
     sorters = {
@@ -955,12 +974,25 @@ async def _gather_results(col, aggs, fields, view):
         if cls and issubclass(cls, fol._HasLabelList):
             name = name[: -(len(cls._LABEL_LIST_FIELD) + 1)]
 
+        data = sorters[type(result)](result, field)
+        result_ticks = 0
+        if type(result) == foa.HistogramValuesResult:
+            result_ticks = ticks.pop(0)
+            if result_ticks is None:
+                result_ticks = []
+                step = max(len(data) // 4, 1)
+                for i in range(0, len(data), step):
+                    result_ticks.append(data[i]["key"])
+
+                if (
+                    result.other > 0
+                    and len(data)
+                    and data[-1]["key"] != "None"
+                ):
+                    result_ticks.append("None")
+
         results.append(
-            {
-                "type": type_,
-                "name": name,
-                "data": sorters[type(result)](result),
-            }
+            {"data": data, "name": name, "ticks": result_ticks, "type": type_,}
         )
 
     return results
@@ -1006,15 +1038,28 @@ async def _numeric_histograms(coll, view, schema, prefix=""):
     aggs = _numeric_bounds(fields, paths)
     bounds = await view._async_aggregate(coll, aggs)
     aggregations = []
+    ticks = []
     for result, field, path in zip(bounds, fields, paths):
         range_ = result.bounds
+        bins = _DEFAULT_NUM_HISTOGRAM_BINS
+        num_ticks = None
+        if range_[0] == range_[1]:
+            bins = 1
+
         if range_ == (None, None):
             range_ = (0, 1)
+        elif fos._meets_type(field, fof.IntField):
+            delta = range_[1] - range_[0]
+            range_ = (range_[0] - 0.5, range_[1] + 0.5)
+            if delta < _DEFAULT_NUM_HISTOGRAM_BINS:
+                bins = delta + 1
+                num_ticks = 0
         else:
             range_ = (range_[0], range_[1] + 0.01)
-        aggregations.append(fo.HistogramValues(path, bins=25, range=range_))
+        ticks.append(num_ticks)
+        aggregations.append(fo.HistogramValues(path, bins=bins, range=range_))
 
-    return aggregations, fields
+    return aggregations, fields, ticks
 
 
 def _make_range_expression(f, args):
@@ -1068,6 +1113,9 @@ def _make_filter_stages(dataset, filters):
     return stages
 
 
+_DEFAULT_NUM_HISTOGRAM_BINS = 25
+
+
 class FileHandler(tornado.web.StaticFileHandler):
     def set_headers(self):
         super().set_headers()


Test patch: