bokeh: `legend_label`引数には文字列以外を指定できない

# 環境
* Python 3.12.1
* bokeh 3.4.0
* pandas 2.2.0

# 背景
`scatter()`メソッドの`legend_field`引数を利用して、凡例を設定しています。

```python:sample0.py
from bokeh.io import output_file, save
import pandas
from bokeh.plotting import figure, ColumnDataSource

from bokeh.palettes import Category10

colors = Category10[3]
df = pandas.DataFrame(
    {"x": [1, 2, 3, 4], "y": [1, 4, 9, 16], "type": ["X", "Y", "Y", "Z"]}
)
print(f"{df["type"]=}")
fig = figure()

for index, type in enumerate(df["type"].unique()):
    df2 = df[df["type"] == type]
    source = ColumnDataSource(df2)
    fig.scatter(
        source=source, x="x", y="y", legend_label=type, color=colors[index], size=10
    )

fig.legend.location = "top_left"
fig.legend.title = "Type"
fig.add_layout(fig.legend[0], "left")

output_file("output0.html")
save(fig)
```

```
$ python sample0.py
df["type"]=0    X
1    Y
2    Y
3    Z
Name: type, dtype: object
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/37903/942e2879-bdd1-1295-5227-7f806b2ac953.png)


# 凡例に設定する値に欠損値が含まれる場合

## `legend_label`引数を指定すると`ValueError`が発生する
`legend_label`引数の型は`str`です。

>legend_label (str, optional) –
>Specify that the glyph should produce a single basic legend label in the legend. The legend entry is labeled with the exact text supplied here. [^1]

[^1]: https://docs.bokeh.org/en/latest/docs/reference/plotting/figure.html

したがって、`legend_label`引数に欠損値を指定すると`ValueError`が発生しました。

```python
df = pandas.DataFrame(
    {"x": [1, 2, 3, 4], "y": [1, 4, 9, 16], "type": ["X", "Y", "Y", float("nan")]}
)
```


```
$ python sample0.py
df["type"]=0      X
1      Y
2      Y
3    NaN
Name: type, dtype: object
...
ValueError: legend_label value must be a string
```

## 回避策: 欠損値を空文字にする

回避策というほどのものではありませんが、欠損値を空文字に置換すれば`ValueError`は発生しません。

```python
df = pandas.DataFrame(
    {"x": [1, 2, 3, 4], "y": [1, 4, 9, 16], "type": ["X", "Y", "Y", float("nan")]}
)
df = df.fillna({"type": ""})
```

```
$ python sample0.py
df["type"]=0    X
1    Y
2    Y
3
Name: type, dtype: object
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/37903/cc24a58c-36a2-e121-8c2f-3ab814a75901.png)


# 凡例に数値を設定する場合

## `type`列のdtypeが`int64`
`type`列が数値の場合は、`legend_label`ではなく`legend_group`引数を利用すれば、DataFrameを変換することなくグラフを描画できました。

```python:sample02.py
df = pandas.DataFrame(
    {
        "x": [1, 2, 3, 4],
        "y": [1, 4, 9, 16],
        "type": [1, 2, 2, 3],
        "color": [colors[0], colors[1], colors[1], colors[2]],
    }
)
print(f"{df["type"]=}")
fig = figure()
source = ColumnDataSource(df)
fig.scatter(source=source, x="x", y="y", legend_group="type", color="color", size=10)
```


```
$ python sample02.py
df["type"]=0    1
1    2
2    2
3    3
Name: type, dtype: int64
```


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/37903/34d39403-9946-209e-cc70-0ae393c99210.png)

## `type`列のdtypeが`Int64`で`pd.NA`を含む

欠損値`pd.NA`が含まれていても描画できました。ただし、凡例の値は整数でなく少数表記でした。bokehで`pd.NA`が`np.nan`に変換されているようにみえます。


```python:sample02.py
df = pandas.DataFrame(
    {
        "x": [1, 2, 3, 4],
        "y": [1, 4, 9, 16],
        "type": [1, 2, 2, None],
        "color": [colors[0], colors[1], colors[1], colors[2]],
    }
)
df = df.astype({"type": "Int64"})
```

```
$ python sample02.py
df["type"]=0       1
1       2
2       2
3    <NA>
Name: type, dtype: Int64
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/37903/dd4a19ca-c421-bb0b-7d70-4d78d52e8202.png)


# 補足

## `legend_group`引数を指定すると、凡例の項目ごとに表示またミュートができない
以下のコードでは`fig.legend.click_policy = "mute"`を指定しています。
しかし、凡例の項目`1`をクリックしても、すべてのプロットがミュートされるだけで、`type`が`1`のプロットだけをミュートすることはできません。



```python:sample04.py
from bokeh.io import output_file, save
import pandas
from bokeh.plotting import figure, ColumnDataSource

from bokeh.palettes import Category10

colors = Category10[3]
df = pandas.DataFrame(
    {
        "x": [1, 2, 3, 4],
        "y": [1, 4, 9, 16],
        "type": [1, 2, 2, 3],
        "color": [colors[0], colors[1], colors[1], colors[2]],
    }
)
df = df.astype({"type": "Int64"})
fig = figure()
source = ColumnDataSource(df)
fig.scatter(source=source, x="x", y="y", legend_group="type", color="color", size=10)

fig.legend.location = "top_left"
fig.legend.title = "Type"
fig.legend.click_policy = "mute"
fig.add_layout(fig.legend[0], "left")

output_file("output04.html")
save(fig)
```

![bokeh-click-policy.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/37903/f400003c-51a7-6a4a-02c2-6325bad9b481.gif)


https://discourse.bokeh.org/t/how-to-activate-the-click-policy-for-group-legends/8984




## 欠損値を含むdtypeが`string`の列は、`legend_group`引数で凡例を設定できない

`type`列のdtypeは`string`で欠損値`pd.NA`が含まれています。
`legend_group`引数に`type`を指定すると、`TypeError`が発生しました。

```python:sample02.py
df = pandas.DataFrame(
    {
        "x": [1, 2, 3, 4],
        "y": [1, 4, 9, 16],
        "type": ["X", "Y", "Y", None],
        "color": [colors[0], colors[1], colors[1], colors[2]],
    }
)
df = df.astype({"type": "string"})
```


```
$ python:sample02.py
df["type"]=0       X
1       Y
2       Y
3    <NA>
Name: type, dtype: string
Traceback (most recent call last):
...
  File "/home/yuji/.pyenv/versions/3.12.1/lib/python3.12/site-packages/numpy/lib/arraysetops.py", line 333, in _unique1d
    perm = ar.argsort(kind='mergesort' if return_index else 'quicksort')
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "missing.pyx", line 392, in pandas._libs.missing.NAType.__bool__
TypeError: boolean value of NA is ambiguous
```

`type`列のdtypeが`string`でなく`object`でも、`TypeError`は発生しました。ただし、エラーメッセージは異なりました。

```python:sample02.py
df = pandas.DataFrame(
    {
        "x": [1, 2, 3, 4],
        "y": [1, 4, 9, 16],
        "type": ["X", "Y", "Y", None],
        "color": [colors[0], colors[1], colors[1], colors[2]],
    }
)
```

```
$ python sample02.py
df["type"]=0       X
1       Y
2       Y
3    None
Name: type, dtype: object
Traceback (most recent call last):
...
File "/home/yuji/.pyenv/versions/3.12.1/lib/python3.12/site-packages/numpy/lib/arraysetops.py", line 333, in _unique1d
    perm = ar.argsort(kind='mergesort' if return_index else 'quicksort')
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: '<' not supported between instances of 'str' and 'NoneType'
```


:::note info
bokehのissueで報告した方がよいかもしれません。
:::