Skip to content
89 changes: 81 additions & 8 deletions tests/python_package_test/test_pandas.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# coding: utf-8
from typing import Any, Dict
from zoneinfo import ZoneInfo

import numpy as np
Expand All @@ -11,6 +12,22 @@
pd = pytest.importorskip("pandas")


# ----------------------------------------------------------------------------------------------- #
# UTILITIES #
# ----------------------------------------------------------------------------------------------- #


def dummy_dataset_params() -> Dict[str, Any]:
return {
"min_data_in_bin": 1,
"min_data_in_leaf": 1,
}


# ----------------------------------------------------------------------------------------------- #
# UNIT TESTS #
# ----------------------------------------------------------------------------------------------- #

# ------------------------------------------- CATEGORICAL ----------------------------------------- #


Expand All @@ -32,7 +49,7 @@ def test_pandas_categorical_encoding(tmp_path):
)
y = [0, 1, 0, 1, 0]

ds = lgb.Dataset(df, label=y, params={"min_data_in_bin": 1})
ds = lgb.Dataset(df, label=y, params=dummy_dataset_params())
ds.construct()

assert ds.num_data() == 5
Expand All @@ -55,7 +72,7 @@ def test_pandas_categorical_encoding(tmp_path):
"num_col": [1.0, 2.0, 3.0, 4.0, 5.0],
}
)
ref_ds = lgb.Dataset(ref_df, label=y, categorical_feature=[0, 1], params={"min_data_in_bin": 1})
ref_ds = lgb.Dataset(ref_df, label=y, categorical_feature=[0, 1], params=dummy_dataset_params())
ref_ds.construct()

assert_datasets_equal(tmp_path, ds, ref_ds)
Expand All @@ -66,12 +83,11 @@ def test_pandas_categorical_encoding_unseen_category(tmp_path):
train_values = ["a", "b", "c", "a", "b"]
valid_values = ["a", "c", "d", "d", "a"] # "d" is unseen in training data

params = {"min_data_in_bin": 1, "min_data_in_leaf": 1}
train_df = pd.DataFrame({"cat_col": pd.Categorical(train_values), "num_col": [1.0, 2.0, 3.0, 4.0, 5.0]})
valid_df = pd.DataFrame({"cat_col": pd.Categorical(valid_values), "num_col": [6.0, 7.0, 8.0, 9.0, 10.0]})

train_ds = lgb.Dataset(train_df, label=[0, 1, 0, 1, 0], params=params)
valid_ds = lgb.Dataset(valid_df, label=[1, 0, 1, 0, 1], reference=train_ds, params=params)
train_ds = lgb.Dataset(train_df, label=[0, 1, 0, 1, 0], params=dummy_dataset_params())
valid_ds = lgb.Dataset(valid_df, label=[1, 0, 1, 0, 1], reference=train_ds, params=dummy_dataset_params())
train_ds.construct()
valid_ds.construct()

Expand All @@ -82,12 +98,69 @@ def test_pandas_categorical_encoding_unseen_category(tmp_path):
"num_col": [6.0, 7.0, 8.0, 9.0, 10.0],
}
)
ref_valid_ds = lgb.Dataset(ref_valid_df, label=[1, 0, 1, 0, 1], reference=train_ds, params=params)
ref_valid_ds = lgb.Dataset(ref_valid_df, label=[1, 0, 1, 0, 1], reference=train_ds, params=dummy_dataset_params())
ref_valid_ds.construct()

assert_datasets_equal(tmp_path, valid_ds, ref_valid_ds)


def test_pandas_categorical_encoding_registered_but_unobserved(tmp_path):
# Train sees: ordered a(0),c(2) and unordered a(0),b(1)
train_df = pd.DataFrame(
{
"ordered_col": pd.Categorical(["a", "c", "c"], categories=["a", "b", "c", "d"], ordered=True),
"unordered_col": pd.Categorical(["a", "b", "b"], categories=["a", "b", "c"]),
}
)
train_ds = lgb.Dataset(train_df, label=[0, 1, 0], params=dummy_dataset_params())
train_ds.construct()

# Valid: ordered has b(1, interpolated) and d(3, clipped); unordered has c(2, unseen)
valid_df = pd.DataFrame(
{
"ordered_col": pd.Categorical(["a", "b", "d"], categories=["a", "b", "c", "d"], ordered=True),
"unordered_col": pd.Categorical(["a", "c", "b"], categories=["a", "b", "c"]),
}
)
valid_ds = lgb.Dataset(valid_df, label=[0, 1, 0], reference=train_ds, params=dummy_dataset_params())
valid_ds.construct()

# Reference: ordered b,d both map to same bin as c; unordered c maps to NaN
ref_valid_df = pd.DataFrame(
{
"ordered_col": pd.Categorical(["a", "c", "c"], categories=["a", "b", "c", "d"], ordered=True),
"unordered_col": pd.Categorical(["a", None, "b"], categories=["a", "b", "c"]),
}
)
ref_valid_ds = lgb.Dataset(ref_valid_df, label=[0, 1, 0], reference=train_ds, params=dummy_dataset_params())
ref_valid_ds.construct()

assert_datasets_equal(tmp_path, valid_ds, ref_valid_ds)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This tests that these Datasets are equal but they could also be equal in the case of a bug where these inputs were all handled incorrectly. For example, if they were all converted to floats and treated as continuous variables.

I think this test should be strengthened with some assertions that the variables were handled in the expected way.

Like:

  • both columns were detected as categorical
  • the encodings in Dataset are as expected (I think you could see this in a model file if you added an lgb.train() to this or output of Dataset._dump_text(), don't recall exactly and can't spend the time to look right now)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I've rewritten the test a little bit to improve the strictness.

  • Aligned the (relative) values and categories between ordered / unordered to emphasize the differences in how they are handled.
  • Check more metadata (e.g. pandas_categorical and params["categorical_column"])
  • Inspect the resulting encodings from _data_from_pandas directly (so python-side, but I've also kept the C++ side verification with a comment explaining why the output is expected)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yep that's good for this PR, much stricter, thank you.



@pytest.mark.parametrize("missing_value", [None, np.nan])
Comment thread
jameslamb marked this conversation as resolved.
Outdated
def test_pandas_categorical_with_missing_values(tmp_path, missing_value):
categories = ["a", "b"]
values = ["a", "b", missing_value, "a", missing_value]

df = pd.DataFrame({"cat": pd.Categorical(values, categories=categories), "num": [1.0, 2.0, 3.0, 4.0, 5.0]})
y = [0, 1, 0, 1, 0]

ds = lgb.Dataset(df, label=y, params=dummy_dataset_params())
ds.construct()
assert ds.pandas_categorical[0] == categories

ref_df = pd.DataFrame(
{
"cat": [0.0, 1.0, np.nan, 0.0, np.nan],
"num": [1.0, 2.0, 3.0, 4.0, 5.0],
}
)
ref_ds = lgb.Dataset(ref_df, label=y, categorical_feature=[0], params=dummy_dataset_params())
ref_ds.construct()
assert_datasets_equal(tmp_path, ds, ref_ds)


def test_pandas_dataset_construction_with_high_cardinality_categorical_succeeds(rng):
X = pd.DataFrame({"x1": rng.integers(low=0, high=5_000, size=(10_000,))})
y = rng.uniform(size=(10_000,))
Expand Down Expand Up @@ -158,7 +231,7 @@ def test_pandas_supported_dtypes(tmp_path, dtype, values):
df = pd.DataFrame({"test_col": pd.Series(values, dtype=dtype), "num_col": [4.0, 5.0, 6.0]})
y = [0, 1, 0]

ds = lgb.Dataset(df, label=y, params={"min_data_in_bin": 1})
ds = lgb.Dataset(df, label=y, params=dummy_dataset_params())
ds.construct()

assert ds.num_data() == 3
Expand All @@ -168,7 +241,7 @@ def test_pandas_supported_dtypes(tmp_path, dtype, values):

# Verify values are preserved
ref_df = pd.DataFrame({"test_col": values, "num_col": [4.0, 5.0, 6.0]})
ref_ds = lgb.Dataset(ref_df, label=y, params={"min_data_in_bin": 1})
ref_ds = lgb.Dataset(ref_df, label=y, params=dummy_dataset_params())
ref_ds.construct()

assert_datasets_equal(tmp_path, ds, ref_ds)
Expand Down
Loading