Skip to content

[BUG] Representativeness gets hard coded to "local" normalizationΒ #8351

Description

@tigmoore

πŸ—ƒοΈ Describe the problem

In the _cluster_ranker function, the norm method can be set to either 'global' or 'local'. However, within the function itself, this value gets hard coded to 'local'. Then, with an elif statement, the local normalization branch ends up setting centerness_ranking = sample_dists. This is the inverse of what should be happening, as distance goes up, we expect that centerness ranking should decrease.

πŸ–₯️ Operating System Platform & Distribution

  • Linux
  • MacOS
  • Windows
  • Other (please specify)

πŸ’» System Details


---OS---
Linux BOPA-Nitro-ANV15-52 7.0.0-29-generic #29~24.04.2-Ubuntu SMP PREEMPT_DYNAMIC Wed Aug 12 17:25:56 UTC 2 x86_64 x86_64 x86_64 GNU/Linux
---HWinfo---
/0/0 memory 31GiB System memory
/0/1 processor 13th Gen Intel(R) Core(TM) i7-13620H
/0/100/2 /dev/fb0 display Raptor Lake-P [UHD Graphics]
/0/100/6/0 display NVIDIA Corporation
/0/100/e storage Volume Management Device NVMe RAID Controller Intel Corporation
/0/100/14.2 memory RAM memory
/0/6.2/0 storage 3500 NVMe SSD
/1 /dev/nvme0 storage Micron_3500_MTFDKBA1T0TGD-1BK15ABYY

🌐 Browser

Not applicable

‡️ Installation type


  • Package (pip)
  • Source (git)

⌨️ Code to reproduce issue


"""
Minimal reproduction: fiftyone.brain.compute_representativeness() (method=
"cluster-center", the default) returns values with inverted semantics.

The field is documented/named as "representativeness": high value = sample is
close to a cluster center = typical/representative. In practice, the value
returned is the sample's *distance* to its cluster center, normalized within
that cluster -- i.e. an outlier score, not a representativeness score. A
sample sitting exactly on its cluster's center gets a value near 0; a sample
at the far edge of its cluster gets a value near 1.

Root cause (traced in fiftyone/brain/internal/core/representativeness.py,
function `_cluster_ranker`, lines ~186-231):

    centerness_ranking = 1 / (1 + sample_dists)   # correct: high = central

    norm_method = "local"          # hardcoded, ignores the `norm_method` arg
    if norm_method == "global":
        centerness_ranking = centerness_ranking / centerness_ranking.max()
    elif norm_method == "local":
        for unique_id in unique_ids:
            cluster_indices = np.where(cluster_ids == unique_id)[0]
            cluster_dists = sample_dists[cluster_indices]
            cluster_dists /= cluster_dists.max()
            sample_dists[cluster_indices] = cluster_dists
        centerness_ranking = sample_dists   # <-- overwrites centerness with
                                             #     normalized raw distance

`norm_method` is hardcoded on the line right before the branch, so the
"global" branch is unreachable regardless of any argument passed through the
public API. In the "local" branch that always runs, the final assignment
discards the correctly-computed `centerness_ranking` from two lines above and
replaces it with the (per-cluster-normalized) raw *distance* -- the inverse
of centerness.

This script builds a synthetic 2D embedding space (1000 points in two
well-separated Gaussian blobs) and shows that the returned
"representativeness" field is essentially a rescaling of each point's
distance to its own KMeans cluster center -- not the inverse of it as the
name/docs imply. It independently re-runs the same clustering
(KMeans, n_clusters=20, random_state=1234 -- matching the internal
algorithm's hardcoded parameters) purely to compute each point's true
distance-to-assigned-center for comparison; this does not depend on any
internal fiftyone.brain function.

No external data/images required -- embeddings are synthetic and a tiny
blank image is generated per sample so FiftyOne has a valid filepath.
"""

import os
import tempfile

import numpy as np
from PIL import Image
from sklearn.cluster import KMeans

import fiftyone as fo
import fiftyone.brain as fob

np.random.seed(0)

# Two well-separated Gaussian blobs, 500 points each -- large enough that
# KMeans(n_clusters=20) (fiftyone-brain's hardcoded cluster count) doesn't
# produce degenerate near-empty clusters.
cluster_a = np.random.normal(loc=[0, 0], scale=1.0, size=(500, 2))
cluster_b = np.random.normal(loc=[30, 30], scale=1.0, size=(500, 2))
embeddings = np.vstack([cluster_a, cluster_b]).astype(np.float32)

tmpdir = tempfile.mkdtemp()
filepaths = []
for i in range(len(embeddings)):
    path = os.path.join(tmpdir, f"{i}.jpg")
    Image.new("RGB", (4, 4)).save(path)
    filepaths.append(path)

dataset = fo.Dataset()
dataset.add_samples([fo.Sample(filepath=p) for p in filepaths])

fob.compute_representativeness(
    dataset,
    embeddings=embeddings,
    representativeness_field="representativeness",
)

returned = np.array(dataset.values("representativeness"))

# Recompute each point's true distance to its own cluster center, using the
# exact same clustering fiftyone-brain uses internally (KMeans, N=20,
# random_state=1234; see _cluster_ranker in representativeness.py).
clusterer = KMeans(n_clusters=20, random_state=1234).fit(embeddings)
dists_to_center = np.linalg.norm(
    embeddings - clusterer.cluster_centers_[clusterer.labels_], axis=1
)

r = np.corrcoef(returned, dists_to_center)[0, 1]
print("Correlation between returned 'representativeness' and each point's")
print("true distance to its own cluster center:")
print(f"  r = {r:.4f}")
print(
    "  (expected ~ -1 if 'representativeness' means centerness/typicality;"
    " observed ~ +1 confirms the field is actually a distance/outlier score)\n"
)

closest_idx = np.argmin(dists_to_center)
farthest_idx = np.argmax(dists_to_center)
print(
    f"Point closest to its cluster center (dist={dists_to_center[closest_idx]:.4f}): "
    f"representativeness = {returned[closest_idx]:.4f}  (expected: high, near 1)"
)
print(
    f"Point farthest from its cluster center (dist={dists_to_center[farthest_idx]:.4f}): "
    f"representativeness = {returned[farthest_idx]:.4f}  (expected: low, near 0)"
)

dataset.delete()

🐍 What version of Python?


Python 3.12.3

πŸ”– What version of FiftyOne?

FiftyOne 1.21.0

πŸͺ΅ Other info/logs


No response

πŸš€ Willingness to contribute


Yes, I can contribute a fix for this bug independently

πŸ“Έ Upload screenshots


No response

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugBug fixes

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions