Skip to content

Commit bb60820

Browse files
committed
refactor(_utils): simplify NaN id recoding for categoricals in _id_var
Both the drop and no-drop paths now share one recoding of the categorical codes, and the non-categorical path uses match(start=1) instead of a manual increment.
1 parent d0d0f0b commit bb60820

3 files changed

Lines changed: 40 additions & 21 deletions

File tree

plotnine/_utils/__init__.py

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -355,48 +355,54 @@ def len_unique(x):
355355

356356
def _id_var(x: AnyArrayLike, drop: bool = False) -> list[int]:
357357
"""
358-
Assign ids to items in x. If two items
359-
are the same, they get the same id.
358+
Assign ids to items in x
359+
360+
If two items are the same, they get the same id.
361+
The ids start at 1 and, for categorical data, NaNs
362+
get the highest id.
360363
361364
Parameters
362365
----------
363366
x : array_like
364367
items to associate ids with
365368
drop : bool
366369
Whether to drop unused factor levels
370+
371+
Returns
372+
-------
373+
ids:
374+
List of ids
367375
"""
368376
if len(x) == 0:
369377
return []
370378

371379
if isinstance(x, pd.Series) and array_kind.categorical(x):
380+
# The ids are a "re-coding" of the categorical codes/levels
381+
# to meet the output requirements.
372382
if drop:
373383
x = x.cat.remove_unused_categories()
374-
lst = list(x.cat.codes + 1)
375-
if 0 in lst:
376-
new_nan_code = max(lst) + 1
377-
lst = [val if val != 0 else new_nan_code for val in lst]
378-
else:
379-
has_nan = any(np.isnan(i) for i in x if isinstance(i, float))
380-
if has_nan:
381-
# NaNs are -1, we give them the highest code
382-
nan_code = -1
383-
new_nan_code = np.max(x.cat.codes) + 1
384-
# TODO: We are assuming that x is of type Sequence[int|nan]
385-
# is that accurate.
386-
lst = [val if val != nan_code else new_nan_code for val in x]
387-
else:
388-
lst = list(x.cat.codes + 1)
384+
385+
codes = x.cat.codes
386+
387+
# We want our list to start at 1.
388+
# But NaNs are -1, and if we have them, we want them to have
389+
# the highest code, i.e. to be ordered last.
390+
ids = list(codes + 1)
391+
has_nan = (codes == -1).any()
392+
if has_nan:
393+
# The NaNs now have an id of 0
394+
highest_id = max(ids) + 1
395+
ids = [highest_id if i == 0 else i for i in ids]
389396
else:
390397
try:
391398
levels = sorted(set(x))
392399
except TypeError:
393400
# x probably has NANs
394401
levels = multitype_sort(list(set(x)))
395402

396-
lst = match(x, levels)
397-
lst = [item + 1 for item in lst]
403+
ids = list(match(x, levels, start=1))
398404

399-
return lst
405+
return ids
400406

401407

402408
def join_keys(x, y, by=None):

tests/test_facets.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ def test_facet_grid_with_missing_categorical_values():
235235
}
236236
)
237237

238-
p = ggplot(data, aes("x", "y")) + geom_point() + facet_grid("row ~ col")
238+
p = ggplot(data, aes("x", "y")) + geom_point() + facet_grid("row", "col")
239239

240240
# No exception
241241
p.draw_test() # pyright: ignore

tests/test_utils.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,19 @@ def test_ninteraction_drops_unused_categorical_levels_with_missing():
144144
assert ninteraction(data, drop=True) == [1, 2, 3, 1]
145145

146146

147+
def test_ninteraction_categorical_missing_values_get_highest_id():
148+
data = pd.DataFrame(
149+
{
150+
"a": pd.Categorical(
151+
["x", "y", None, "x"],
152+
categories=["x", "unused", "y"],
153+
)
154+
}
155+
)
156+
157+
assert ninteraction(data, drop=False) == [1, 3, 4, 1]
158+
159+
147160
def test_ninteraction_datetime_series():
148161
# When a pandas datetime is converted Numpy datetime, the two
149162
# no longer compare as equal! This test ensures that case is

0 commit comments

Comments
 (0)