Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 1 addition & 11 deletions nvflare/app_common/statistics/numpy_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,22 +69,12 @@ def get_std_histogram_buckets(nums: np.ndarray, num_bins: int = 10, br: Optional
if bucket_count == 0 and num_neginf > 0:
bucket_low_value = float("-inf")
bucket_sample_count += num_neginf
elif bucket_count == len(counts) - 1 and num_posinf > 0:
if bucket_count == len(counts) - 1 and num_posinf > 0:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Mixed clients lose histogram counts

When one client has infinite values and another does not, their outer bins have different boundaries. For example, with three bins over (-1, 1), [-inf, 0, inf] produces infinite outer edges, while [-1, 0, 1] produces finite ones. accumulate_hists matches bins by exact boundaries and keeps only the first client's bins, so the combined histogram counts four of the six samples. The new two-client test gives both clients the same infinity pattern and does not catch this case.

Knowledge Base Used: Built-in application components

bucket_high_value = float("inf")
bucket_sample_count += num_posinf

histogram_buckets.append(
Bin(low_value=bucket_low_value, high_value=bucket_high_value, sample_count=bucket_sample_count)
)

if buckets is not None and len(buckets) > 0:
bucket = None
if num_neginf:
bucket = Bin(low_value=float("-inf"), high_value=float("-inf"), sample_count=num_neginf)
if num_posinf:
bucket = Bin(low_value=float("inf"), high_value=float("inf"), sample_count=num_posinf)

if bucket:
histogram_buckets.append(bucket)

return histogram_buckets
61 changes: 59 additions & 2 deletions tests/unit_test/app_common/statistics/numpy_utils_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@
import pandas as pd
import pytest

from nvflare.app_common.abstract.statistics_spec import DataType
from nvflare.app_common.statistics.numpy_utils import dtype_to_data_type
from nvflare.app_common.abstract.statistics_spec import BinRange, DataType
from nvflare.app_common.statistics.numeric_stats import accumulate_hists
from nvflare.app_common.statistics.numpy_utils import dtype_to_data_type, get_std_histogram_buckets
from nvflare.app_opt.statistics.df.df_core_statistics import DFStatisticsCore


class TestDtypeToDataType:
Expand Down Expand Up @@ -61,3 +63,58 @@ def test_pandas_nullable_float_dtype(self):
def test_pandas_nullable_bool_dtype(self):
# pd.BooleanDtype is a nullable ExtensionDtype — must map to INT, not STRING
assert dtype_to_data_type(pd.BooleanDtype()) == DataType.INT


class TestHistogramInfinityCounts:
@pytest.mark.parametrize(
"values,num_bins",
[
([-1.0, 0.0, 1.0], 3),
([-np.inf, -np.inf, 0.0], 3),
([0.0, np.inf, np.inf], 3),
([-np.inf, 0.0, np.inf], 3),
([-np.inf, 0.0, np.inf], 1),
([-np.inf, np.inf], 1),
([np.nan, -np.inf, 0.0, np.inf], 3),
([-2.0, -1.0, 0.0, 1.0, 2.0], 3),
([], 3),
],
)
def test_infinities_counted_once_in_edge_buckets(self, values, num_bins):
# A read-only strided view verifies that input data is not modified.
backing = np.repeat(np.asarray(values, dtype=np.float64), 2)
nums = backing[::2]
nums.setflags(write=False)
counts, edges = np.histogram(nums[np.isfinite(nums)], bins=num_bins, range=(-1.0, 1.0))
counts[0] += np.isneginf(nums).sum()
counts[-1] += np.isposinf(nums).sum()
expected_edges = edges.copy()
if np.isneginf(nums).any():
expected_edges[0] = -np.inf
if np.isposinf(nums).any():
expected_edges[-1] = np.inf

buckets = get_std_histogram_buckets(nums, num_bins, BinRange(-1.0, 1.0))
assert len(buckets) == num_bins
np.testing.assert_array_equal([b.sample_count for b in buckets], counts)
np.testing.assert_array_equal([b.low_value for b in buckets], expected_edges[:-1])
np.testing.assert_array_equal([b.high_value for b in buckets], expected_edges[1:])
assert sum(b.sample_count for b in buckets) == sum(counts)
np.testing.assert_array_equal(nums, values)

def test_dataframe_and_global_histogram_preserve_sample_count(self):
global_histograms = {}
expected_count = 0
for values in ([-np.inf, 0.0, np.inf], [-np.inf, -0.5, 0.5, np.inf, np.nan]):
statistics = DFStatisticsCore()
statistics.data = {"train": pd.DataFrame({"value": values})}
original = statistics.data["train"].copy(deep=True)
histogram = statistics.histogram("train", "value", 3, -1.0, 1.0)
expected_count += statistics.count("train", "value")
global_histograms = accumulate_hists({"train": {"value": histogram}}, global_histograms)
pd.testing.assert_frame_equal(statistics.data["train"], original)

buckets = global_histograms["train"]["value"].bins
assert len(buckets) == 3
assert sum(b.sample_count for b in buckets) == expected_count == 7
assert [b.sample_count for b in buckets] == [3, 1, 3]