Repository: modin-project/modin

Issue:
Expansion DMatrix on 6 new parameters
Need add new parameters and functions in class DMatrix

Support parameters ``missing``, ``feature_names``, ``feature_types``, ``feature_weights``, ``silent``, ``enable_categorical``.


Discussion:

Resolving patch:
diff --git a/modin/experimental/xgboost/xgboost.py b/modin/experimental/xgboost/xgboost.py
--- a/modin/experimental/xgboost/xgboost.py
+++ b/modin/experimental/xgboost/xgboost.py
@@ -37,13 +37,37 @@ class DMatrix:
         Data source of DMatrix.
     label : modin.pandas.DataFrame or modin.pandas.Series, optional
         Labels used for training.
+    missing : float, optional
+        Value in the input data which needs to be present as a missing
+        value. If ``None``, defaults to ``np.nan``.
+    silent : boolean, optional
+        Whether to print messages during construction or not.
+    feature_names : list, optional
+        Set names for features.
+    feature_types : list, optional
+        Set types for features.
+    feature_weights : array_like, optional
+        Set feature weights for column sampling.
+    enable_categorical : boolean, optional
+        Experimental support of specializing for categorical features.
 
     Notes
     -----
-    Currently DMatrix supports only `data` and `label` parameters.
+    Currently DMatrix doesn't support `weight`, `base_margin`, `nthread`,
+    `group`, `qid`, `label_lower_bound`, `label_upper_bound` parameters.
     """
 
-    def __init__(self, data, label=None):
+    def __init__(
+        self,
+        data,
+        label=None,
+        missing=None,
+        silent=False,
+        feature_names=None,
+        feature_types=None,
+        feature_weights=None,
+        enable_categorical=None,
+    ):
         assert isinstance(
             data, pd.DataFrame
         ), f"Type of `data` is {type(data)}, but expected {pd.DataFrame}."
@@ -58,6 +82,21 @@ def __init__(self, data, label=None):
 
         self.data = unwrap_partitions(data, axis=0, get_ip=True)
 
+        self._n_rows = data.shape[0]
+        self._n_cols = data.shape[1]
+
+        for i, dtype in enumerate(data.dtypes):
+            if dtype == "object":
+                raise ValueError(f"Column {i} has unsupported data type {dtype}.")
+
+        self.feature_names = feature_names
+        self.feature_types = feature_types
+
+        self.missing = missing
+        self.silent = silent
+        self.feature_weights = feature_weights
+        self.enable_categorical = enable_categorical
+
         self.metadata = (
             data.index,
             data.columns,
@@ -80,6 +119,170 @@ def __iter__(self):
         yield self.data
         yield self.label
 
+    def get_dmatrix_params(self):
+        """
+        Get dict of DMatrix parameters excluding `self.data`/`self.label`.
+
+        Returns
+        -------
+        dict
+        """
+        dmatrix_params = {
+            "feature_names": self.feature_names,
+            "feature_types": self.feature_types,
+            "missing": self.missing,
+            "silent": self.silent,
+            "feature_weights": self.feature_weights,
+            "enable_categorical": self.enable_categorical,
+        }
+        return dmatrix_params
+
+    @property
+    def feature_names(self):
+        """
+        Get column labels.
+
+        Returns
+        -------
+        Column labels.
+        """
+        return self._feature_names
+
+    @feature_names.setter
+    def feature_names(self, feature_names):
+        """
+        Set column labels.
+
+        Parameters
+        ----------
+        feature_names : list or None
+            Labels for columns. In the case of ``None``, existing feature names will be reset.
+        """
+        if feature_names is not None:
+            feature_names = (
+                list(feature_names)
+                if not isinstance(feature_names, str)
+                else [feature_names]
+            )
+
+            if len(feature_names) != len(set(feature_names)):
+                raise ValueError("Items in `feature_names` must be unique.")
+            if len(feature_names) != self.num_col() and self.num_col() != 0:
+                raise ValueError(
+                    "`feature_names` must have the same width as `self.data`."
+                )
+            if not all(
+                isinstance(f, str) and not any(x in f for x in set(("[", "]", "<")))
+                for f in feature_names
+            ):
+                raise ValueError(
+                    "Items of `feature_names` must be string and must not contain [, ] or <."
+                )
+        else:
+            feature_names = None
+        self._feature_names = feature_names
+
+    @property
+    def feature_types(self):
+        """
+        Get column types.
+
+        Returns
+        -------
+        Column types.
+        """
+        return self._feature_types
+
+    @feature_types.setter
+    def feature_types(self, feature_types):
+        """
+        Set column types.
+
+        Parameters
+        ----------
+        feature_types : list or None
+            Labels for columns. In case None, existing feature names will be reset.
+        """
+        if feature_types is not None:
+            if not isinstance(feature_types, (list, str)):
+                raise TypeError("feature_types must be string or list of strings")
+            if isinstance(feature_types, str):
+                feature_types = [feature_types] * self.num_col()
+                feature_types = (
+                    list(feature_types)
+                    if not isinstance(feature_types, str)
+                    else [feature_types]
+                )
+        else:
+            feature_types = None
+        self._feature_types = feature_types
+
+    def num_row(self):
+        """
+        Get number of rows.
+
+        Returns
+        -------
+        int
+        """
+        return self._n_rows
+
+    def num_col(self):
+        """
+        Get number of columns.
+
+        Returns
+        -------
+        int
+        """
+        return self._n_cols
+
+    def get_float_info(self, name):
+        """
+        Get float property from the DMatrix.
+
+        Parameters
+        ----------
+        name : str
+            The field name of the information.
+
+        Returns
+        -------
+        A NumPy array of float information of the data.
+        """
+        return getattr(self, name)
+
+    def set_info(
+        self,
+        *,
+        label=None,
+        feature_names=None,
+        feature_types=None,
+        feature_weights=None,
+    ) -> None:
+        """
+        Set meta info for DMatrix.
+
+        Parameters
+        ----------
+        label : modin.pandas.DataFrame or modin.pandas.Series, optional
+            Labels used for training.
+        feature_names : list, optional
+            Set names for features.
+        feature_types : list, optional
+            Set types for features.
+        feature_weights : array_like, optional
+            Set feature weights for column sampling.
+        """
+        if label is not None:
+            self.label = label
+        if feature_names is not None:
+            self.feature_names = feature_names
+        if feature_types is not None:
+            self.feature_types = feature_types
+        if feature_weights is not None:
+            self.feature_weights = feature_weights
+
 
 class Booster(xgb.Booster):
     """
@@ -135,6 +338,31 @@ def predict(
             data, DMatrix
         ), f"Type of `data` is {type(data)}, but expected {DMatrix}."
 
+        if (
+            self.feature_names is not None
+            and data.feature_names is not None
+            and self.feature_names != data.feature_names
+        ):
+            data_missing = set(self.feature_names) - set(data.feature_names)
+            self_missing = set(data.feature_names) - set(self.feature_names)
+
+            msg = "feature_names mismatch: {0} {1}"
+
+            if data_missing:
+                msg += (
+                    "\nexpected "
+                    + ", ".join(str(s) for s in data_missing)
+                    + " in input data"
+                )
+
+            if self_missing:
+                msg += (
+                    "\ntraining data did not have the following fields: "
+                    + ", ".join(str(s) for s in self_missing)
+                )
+
+            raise ValueError(msg.format(self.feature_names, data.feature_names))
+
         result = _predict(self.copy(), data, **kwargs)
         LOGGER.info("Prediction finished")
 
diff --git a/modin/experimental/xgboost/xgboost_ray.py b/modin/experimental/xgboost/xgboost_ray.py
--- a/modin/experimental/xgboost/xgboost_ray.py
+++ b/modin/experimental/xgboost/xgboost_ray.py
@@ -59,7 +59,7 @@ def __init__(self, rank, nthread):
             f"Actor <{self._rank}>, nthread = {self._nthreads} was initialized."
         )
 
-    def _get_dmatrix(self, X_y):
+    def _get_dmatrix(self, X_y, **dmatrix_kwargs):
         """
         Create xgboost.DMatrix from sequence of pandas.DataFrame objects.
 
@@ -69,6 +69,8 @@ def _get_dmatrix(self, X_y):
         ----------
         X_y : list
             List of pandas.DataFrame objects.
+        **dmatrix_kwargs : dict
+            Keyword parameters for ``xgb.DMatrix``.
 
         Returns
         -------
@@ -87,9 +89,9 @@ def _get_dmatrix(self, X_y):
         y = pandas.concat(y, axis=0)
         LOGGER.info(f"Concat time: {time.time() - s} s")
 
-        return xgb.DMatrix(X, y, nthread=self._nthreads)
+        return xgb.DMatrix(X, y, nthread=self._nthreads, **dmatrix_kwargs)
 
-    def set_train_data(self, *X_y, add_as_eval_method=None):
+    def set_train_data(self, *X_y, add_as_eval_method=None, **dmatrix_kwargs):
         """
         Set train data for actor.
 
@@ -101,13 +103,15 @@ def set_train_data(self, *X_y, add_as_eval_method=None):
             of ray.ObjectRef -> pandas.DataFrame happens.
         add_as_eval_method : str, optional
             Name of eval data. Used in case when train data also used for evaluation.
+        **dmatrix_kwargs : dict
+            Keyword parameters for ``xgb.DMatrix``.
         """
-        self._dtrain = self._get_dmatrix(X_y)
+        self._dtrain = self._get_dmatrix(X_y, **dmatrix_kwargs)
 
         if add_as_eval_method is not None:
             self._evals.append((self._dtrain, add_as_eval_method))
 
-    def add_eval_data(self, *X_y, eval_method):
+    def add_eval_data(self, *X_y, eval_method, **dmatrix_kwargs):
         """
         Add evaluation data for actor.
 
@@ -119,8 +123,10 @@ def add_eval_data(self, *X_y, eval_method):
             of ray.ObjectRef -> pandas.DataFrame happens.
         eval_method : str
             Name of eval data.
+        **dmatrix_kwargs : dict
+            Keyword parameters for ``xgb.DMatrix``.
         """
-        self._evals.append((self._get_dmatrix(X_y), eval_method))
+        self._evals.append((self._get_dmatrix(X_y, **dmatrix_kwargs), eval_method))
 
     def train(self, rabit_args, params, *args, **kwargs):
         """
@@ -491,6 +497,7 @@ def _train(
     s = time.time()
 
     X_row_parts, y_row_parts = dtrain
+    dmatrix_kwargs = dtrain.get_dmatrix_params()
 
     assert len(X_row_parts) == len(y_row_parts), "Unaligned train data"
 
@@ -526,7 +533,7 @@ def _train(
             _split_data_across_actors(
                 actors,
                 lambda actor, *X_y: actor.add_eval_data.remote(
-                    *X_y, eval_method=eval_method
+                    *X_y, eval_method=eval_method, **dmatrix_kwargs
                 ),
                 eval_X,
                 eval_y,
@@ -536,7 +543,7 @@ def _train(
     _split_data_across_actors(
         actors,
         lambda actor, *X_y: actor.set_train_data.remote(
-            *X_y, add_as_eval_method=add_as_eval_method
+            *X_y, add_as_eval_method=add_as_eval_method, **dmatrix_kwargs
         ),
         X_row_parts,
         y_row_parts,
@@ -560,7 +567,7 @@ def _train(
 
 
 @ray.remote
-def _map_predict(booster, part, columns, **kwargs):
+def _map_predict(booster, part, columns, dmatrix_kwargs={}, **kwargs):
     """
     Run prediction on a remote worker.
 
@@ -572,6 +579,8 @@ def _map_predict(booster, part, columns, **kwargs):
         Partition of full data used for local prediction.
     columns : list or ray.ObjectRef
         Columns for the result.
+    dmatrix_kwargs : dict, optional
+        Keyword parameters for ``xgb.DMatrix``.
     **kwargs : dict
         Other parameters are the same as for ``xgboost.Booster.predict``.
 
@@ -580,7 +589,7 @@ def _map_predict(booster, part, columns, **kwargs):
     ray.ObjectRef
         ``ray.ObjectRef`` with partial prediction.
     """
-    dmatrix = xgb.DMatrix(part)
+    dmatrix = xgb.DMatrix(part, **dmatrix_kwargs)
     prediction = pandas.DataFrame(
         booster.predict(dmatrix, **kwargs),
         index=part.index,
@@ -615,6 +624,7 @@ def _predict(
         Modin DataFrame with prediction results.
     """
     s = time.time()
+    dmatrix_kwargs = data.get_dmatrix_params()
 
     # Get metadata from DMatrix
     input_index, input_columns, row_lengths = data.metadata
@@ -639,7 +649,7 @@ def _get_num_columns(booster, n_features, **kwargs):
     new_columns_ref = ray.put(new_columns)
 
     prediction_refs = [
-        _map_predict.remote(booster, part, new_columns_ref, **kwargs)
+        _map_predict.remote(booster, part, new_columns_ref, dmatrix_kwargs, **kwargs)
         for _, part in data.data
     ]
     predictions = from_partitions(


Test patch:
diff --git a/modin/experimental/xgboost/test/test_dmatrix.py b/modin/experimental/xgboost/test/test_dmatrix.py
new file mode 100644
--- /dev/null
+++ b/modin/experimental/xgboost/test/test_dmatrix.py
@@ -0,0 +1,160 @@
+# Licensed to Modin Development Team under one or more contributor license agreements.
+# See the NOTICE file distributed with this work for additional information regarding
+# copyright ownership.  The Modin Development Team licenses this file to you under the
+# Apache License, Version 2.0 (the "License"); you may not use this file except in
+# compliance with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under
+# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
+# ANY KIND, either express or implied. See the License for the specific language
+# governing permissions and limitations under the License.
+
+import numpy as np
+import pytest
+from sklearn.metrics import accuracy_score
+import xgboost as xgb
+
+import modin.pandas as pd
+import modin.experimental.xgboost as mxgb
+
+
+rng = np.random.RandomState(1994)
+
+
+def check_dmatrix(data, label=None, **kwargs):
+    modin_data = pd.DataFrame(data)
+    modin_label = label if label is None else pd.Series(label)
+    try:
+        dm = xgb.DMatrix(data, label=label, **kwargs)
+    except Exception as xgb_exception:
+        with pytest.raises(Exception) as mxgb_exception:
+            mxgb.DMatrix(modin_data, label=modin_label, **kwargs)
+        # Thrown exceptions are `XGBoostError`, which is a descendant of `ValueError`, and `ValueError`
+        # for XGBoost and Modin, respectively,  so we intentionally use `xgb_exception`
+        # as a first parameter of `isinstance` to pass the assertion
+        assert isinstance(
+            xgb_exception, type(mxgb_exception.value)
+        ), "Got Modin Exception type {}, but xgboost Exception type {} was expected".format(
+            type(mxgb_exception.value), type(xgb_exception)
+        )
+    else:
+        md_dm = mxgb.DMatrix(modin_data, label=modin_label, **kwargs)
+        assert md_dm.num_row() == dm.num_row()
+        assert md_dm.num_col() == dm.num_col()
+        assert md_dm.feature_names == dm.feature_names
+        assert md_dm.feature_types == dm.feature_types
+
+
+@pytest.mark.parametrize(
+    "data",
+    [
+        np.random.randn(5, 5),
+        np.array([[1, 2], [3, 4]]),
+        np.array([["a", "b"], ["c", "d"]]),
+        [[1, 2], [3, 4]],
+        [["a", "b"], ["c", "d"]],
+    ],
+)
+@pytest.mark.parametrize(
+    "feature_names",
+    [
+        list("abcdef"),
+        ["a", "b", "c", "d", "d"],
+        ["a", "b", "c", "d", "e<1"],
+        list("abcde"),
+    ],
+)
+@pytest.mark.parametrize(
+    "feature_types",
+    [None, "q", list("qiqiq")],
+)
+def test_dmatrix_feature_names_and_feature_types(data, feature_names, feature_types):
+    check_dmatrix(data, feature_names=feature_names, feature_types=feature_types)
+
+
+@pytest.mark.parametrize(
+    "feature_names",
+    [
+        ["Feature1", "Feature2", "Feature3", "Feature4", "Feature5"],
+        [u"??1", u"??2", u"??3", u"??4", u"??5"],
+    ],
+)
+def test_feature_names(feature_names):
+    data = np.random.randn(100, 5)
+    label = np.array([0, 1] * 50)
+
+    check_dmatrix(
+        data,
+        label,
+        feature_names=feature_names,
+    )
+
+    dm = xgb.DMatrix(data, label=label, feature_names=feature_names)
+    md_dm = mxgb.DMatrix(
+        pd.DataFrame(data), label=pd.Series(label), feature_names=feature_names
+    )
+
+    params = {
+        "objective": "multi:softprob",
+        "eval_metric": "mlogloss",
+        "eta": 0.3,
+        "num_class": 3,
+    }
+
+    bst = xgb.train(params, dm, num_boost_round=10)
+    md_bst = mxgb.train(params, md_dm, num_boost_round=10)
+
+    predictions = bst.predict(dm)
+    modin_predictions = md_bst.predict(md_dm)
+
+    preds = np.asarray([np.argmax(line) for line in predictions])
+    md_preds = np.asarray([np.argmax(line) for line in modin_predictions.to_numpy()])
+
+    val = accuracy_score(label, preds)
+    md_val = accuracy_score(label, md_preds)
+
+    np.testing.assert_allclose(val, md_val, atol=0.02, rtol=0.01)
+
+    dummy = np.random.randn(5, 5)
+    dm = xgb.DMatrix(dummy, feature_names=feature_names)
+    md_dm = mxgb.DMatrix(pd.DataFrame(dummy), feature_names=feature_names)
+    predictions = bst.predict(dm)
+    modin_predictions = md_bst.predict(md_dm)
+
+    preds = np.asarray([np.argmax(line) for line in predictions])
+    md_preds = np.asarray([np.argmax(line) for line in modin_predictions.to_numpy()])
+
+    assert preds.all() == md_preds.all()
+
+    # different feature names must raises error
+    dm = xgb.DMatrix(dummy, feature_names=list("abcde"))
+    md_dm = mxgb.DMatrix(pd.DataFrame(dummy), feature_names=list("abcde"))
+    with pytest.raises(ValueError):
+        bst.predict(dm)
+    with pytest.raises(ValueError):
+        md_bst.predict(md_dm)
+
+
+def test_feature_weights():
+    n_rows = 10
+    n_cols = 50
+    fw = rng.uniform(size=n_cols)
+    X = rng.randn(n_rows, n_cols)
+    dm = xgb.DMatrix(X)
+    md_dm = mxgb.DMatrix(pd.DataFrame(X))
+    dm.set_info(feature_weights=fw)
+    md_dm.set_info(feature_weights=fw)
+    np.testing.assert_allclose(
+        dm.get_float_info("feature_weights"), md_dm.get_float_info("feature_weights")
+    )
+    # Handle empty
+    dm.set_info(feature_weights=np.empty((0, 0)))
+    md_dm.set_info(feature_weights=np.empty((0, 0)))
+
+    assert (
+        dm.get_float_info("feature_weights").shape[0]
+        == md_dm.get_float_info("feature_weights").shape[0]
+        == 0
+    )