Repository: scikit-learn/scikit-learn
Pull request: FIX `export_text` and `export_graphviz` accepts feature and class names as array-like
#### Reference Issues/PRs

Fixes #26265.

#### What does this implement/fix? Explain your changes.

Make `tree.export_text` accept both feature names and class names as numpy arrays. Before modification, both `tree.export_graphviz` and `tree.export_text` accept can deal with class names which are numpy arrays, but accept only lists during parameter validation. Moreover, `tree.export_graphviz` can deal with feature names which are numpy arrays, but `tree.export_text` cannot, and both of them accept only lists during parameter validation.

This fix makes both `export_text` consistent with `export_graphviz`, i.e., being able to deal with feature names and class names which are array-like. I will change docstring and parameter validation for `export_graphviz` in #26034.

#### Any other comments?

Please let me know if I should add test cases for this.

Problem statement:
sklearn.tree.export_text failing when feature_names supplied
folks, I'm not sure why this works for
```py
import sklearn.tree
print(my_feature_names)
['0' '0 trump' '0 trump versus' ... 'zur' 'zur ckhalten' 'zur ckhalten muss']

tree.export_graphviz(clf, out_file=None, max_depth=4, feature_names=my_feature_names)
```
but not for 

```py
import sklearn.tree
print(my_feature_names)
['0' '0 trump' '0 trump versus' ... 'zur' 'zur ckhalten' 'zur ckhalten muss']

tree.export_text(clf, max_depth=4, feature_names=my_feature_names)

Traceback (most recent call last):
  File "./sample-python-projects/machine-learning/HW1_Q2a.py", line 72, in <module>
    print(tree.export_text(clf, max_depth=4, feature_names=my_feature_names))
  File "C:\Users\sam\python\lib\site-packages\sklearn\tree\_export.py", line 1016, in export_text
    if feature_names:
ValueError: The truth value of an array with more than one element is ambiguous. Use a.any() or a.all()
```

Can anyone help?

Hints from issue discussion:
Could you please post a minimal reproducible? (something we can copy paste in its entirety to produce the issue).
@NickKanellos From the error message, it seems that the feature names you passed in is an array, but as [documented](https://scikit-learn.org/stable/modules/generated/sklearn.tree.export_graphviz.html), `feature_names` must either be a list of strings or `None`.

> feature_nameslist of str, default=None
Names of each of the features. If None, generic names will be used (“x[0]”, “x[1]”, …).
@adrinjalali I can give a piece of code that possibly produces the same error:
```python
from sklearn.datasets import load_iris
from sklearn.tree import DecisionTreeClassifier
from sklearn.tree import export_text
import numpy as np

import sklearn; print("Sklearn version:", sklearn.__version__)

# Loading data, make feature_names a numpy array on purpose
iris = load_iris()
X, y = iris["data"], iris["target"]
feature_names = np.array(iris["feature_names"])

# Training decision tree
decision_tree = DecisionTreeClassifier(random_state=0, max_depth=2).fit(X, y)

# Exporting text
r = export_text(decision_tree, feature_names=feature_names)
print(r)
```
With the current stable version (1.2.2 or earlier), we will have
```
Sklearn version: 1.2.2
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
[<ipython-input-7-e072cc517196>](https://localhost:8080/#) in <cell line: 17>()
     15 
     16 # Exporting text
---> 17 r = export_text(decision_tree, feature_names=feature_names)
     18 print(r)

[/usr/local/lib/python3.9/dist-packages/sklearn/tree/_export.py](https://localhost:8080/#) in export_text(decision_tree, feature_names, max_depth, spacing, decimals, show_weights)
   1014         value_fmt = "{}{} value: {}\n"
   1015 
-> 1016     if feature_names:
   1017         feature_names_ = [
   1018             feature_names[i] if i != _tree.TREE_UNDEFINED else None

ValueError: The truth value of an array with more than one element is ambiguous. Use a.any() or a.all()
```
In the current development branch (main branch), the parameters are validated (see #25867) for `feature_names` to accept only list of strings or `None`, so we will have
```
Sklearn version: 1.3.dev0
Traceback (most recent call last):
  File "D:\Academic\03 - NYU Shanghai\8_Spring 2023\Open Source Software Development\scikit-learn-ossd\test1.py", line 17, in <module>
    r = export_text(decision_tree, feature_names=feature_names)
  File "D:\Academic\03 - NYU Shanghai\8_Spring 2023\Open Source Software Development\scikit-learn-ossd\sklearn\utils\_param_validation.py", line 186, in wrapper
    validate_parameter_constraints(
  File "D:\Academic\03 - NYU Shanghai\8_Spring 2023\Open Source Software Development\scikit-learn-ossd\sklearn\utils\_param_validation.py", line 97, in validate_parameter_constraints
    raise InvalidParameterError(
sklearn.utils._param_validation.InvalidParameterError: The 'feature_names' parameter of export_text must be an instance of 'list' or None. Got array(['sepal length (cm)', 'sepal width (cm)', 'petal length (cm)',
       'petal width (cm)'], dtype='<U17') instead.
```
However, though `export_graphviz` is also documented to accept `feature_name` only as list of strings or `None` (see also #26034, though not merged yet), it actually works with numpy arrays without a problem. I think we can make `export_graphviz` and `export_text` consistent (i.e., both accept numpy arrays). To do this, I can edit the docstring and parameter validation in #26034, and for `export_text`, by changing the line
https://github.com/scikit-learn/scikit-learn/blob/4caa4abf8224681962655f30a6fc8eb67f7749c8/sklearn/tree/_export.py#L1040
to `if feature_names is not None:`, and changing the line
https://github.com/scikit-learn/scikit-learn/blob/4caa4abf8224681962655f30a6fc8eb67f7749c8/sklearn/tree/_export.py#L927
to `"feature_names": ["array-like", None],` and then running the same code snippet as above, we will be able get the desired result such that
```
Sklearn version: 1.3.dev0
|--- petal width (cm) <= 0.80
|   |--- class: 0
|--- petal width (cm) >  0.80
|   |--- petal width (cm) <= 1.75
|   |   |--- class: 1
|   |--- petal width (cm) >  1.75
|   |   |--- class: 2
```
If this is the desired behavior, I'm happy to make a PR for this.
- [ ] Change docstring and parameter validation in #26034 (this PR is made by me so it is convenient to make a change)
- [x] Change docstring and parameter validation in `sklearn.tree.export_text` (accept "array-like")
- [x] Change `if feature_names:` to `if feature_names is not None:` in `sklearn.tree.export_text`
we're not really doing anything which definitely requires feature names to be a list, I'm happy with a fix which would accept an array like, and convert it to a list or an array. @Charlie-XIAO would you have time to submit a PR for your suggestions?  
@adrinjalali Sure, I will do it right now.
/take

Commit message: added changelog

Patch to review:
diff --git a/doc/whats_new/v1.3.rst b/doc/whats_new/v1.3.rst
index bb245aa466152..6a50d0cb00d1a 100644
--- a/doc/whats_new/v1.3.rst
+++ b/doc/whats_new/v1.3.rst
@@ -491,6 +491,10 @@ Changelog
   for each target class in ascending numerical order.
   :pr:`25387` by :user:`William M <Akbeeh>` and :user:`crispinlogan <crispinlogan>`.
 
+- |Fix| :func:`tree.export_graphviz` and :func:`tree.export_text` now accepts
+  `feature_names` and `class_names` as array-like rather than lists.
+  :pr:`26289` by :user:`Yao Xiao <Charlie-XIAO>`
+
 :mod:`sklearn.utils`
 ....................
 
diff --git a/sklearn/tree/_export.py b/sklearn/tree/_export.py
index 6b84bed891c18..9450bf00a9afc 100644
--- a/sklearn/tree/_export.py
+++ b/sklearn/tree/_export.py
@@ -788,11 +788,11 @@ def export_graphviz(
         The maximum depth of the representation. If None, the tree is fully
         generated.
 
-    feature_names : list of str, default=None
-        Names of each of the features.
+    feature_names : array-like of shape(n_features,), default=None
+        An array containing the feature names.
         If None, generic names will be used ("x[0]", "x[1]", ...).
 
-    class_names : list of str or bool, default=None
+    class_names : array-like or bool, default=None
         Names of each of the target classes in ascending numerical order.
         Only relevant for classification and not supported for multi-output.
         If ``True``, shows a symbolic representation of the class name.
@@ -924,8 +924,8 @@ def compute_depth_(
 @validate_params(
     {
         "decision_tree": [DecisionTreeClassifier, DecisionTreeRegressor],
-        "feature_names": [list, None],
-        "class_names": [list, None],
+        "feature_names": ["array-like", None],
+        "class_names": ["array-like", None],
         "max_depth": [Interval(Integral, 0, None, closed="left"), None],
         "spacing": [Interval(Integral, 1, None, closed="left"), None],
         "decimals": [Interval(Integral, 0, None, closed="left"), None],
@@ -953,16 +953,16 @@ def export_text(
         It can be an instance of
         DecisionTreeClassifier or DecisionTreeRegressor.
 
-    feature_names : list of str, default=None
-        A list of length n_features containing the feature names.
+    feature_names : array-like of shape (n_features,), default=None
+        An array containing the feature names.
         If None generic names will be used ("feature_0", "feature_1", ...).
 
-    class_names : list or None, default=None
+    class_names : array-like or None, default=None
         Names of each of the target classes in ascending numerical order.
         Only relevant for classification and not supported for multi-output.
 
         - if `None`, the class names are delegated to `decision_tree.classes_`;
-        - if a list, then `class_names` will be used as class names instead
+        - if an array, then `class_names` will be used as class names instead
           of `decision_tree.classes_`. The length of `class_names` must match
           the length of `decision_tree.classes_`.
 
@@ -1015,7 +1015,7 @@ def export_text(
             class_names = decision_tree.classes_
         elif len(class_names) != len(decision_tree.classes_):
             raise ValueError(
-                "When `class_names` is a list, it should contain as"
+                "When `class_names` is an array, it should contain as"
                 " many items as `decision_tree.classes_`. Got"
                 f" {len(class_names)} while the tree was fitted with"
                 f" {len(decision_tree.classes_)} classes."
@@ -1037,7 +1037,7 @@ def export_text(
     else:
         value_fmt = "{}{} value: {}\n"
 
-    if feature_names:
+    if feature_names is not None:
         feature_names_ = [
             feature_names[i] if i != _tree.TREE_UNDEFINED else None
             for i in tree_.feature
diff --git a/sklearn/tree/tests/test_export.py b/sklearn/tree/tests/test_export.py
index 8cdf28b8f7130..191b3d9891df0 100644
--- a/sklearn/tree/tests/test_export.py
+++ b/sklearn/tree/tests/test_export.py
@@ -352,7 +352,7 @@ def test_export_text_errors():
     with pytest.raises(ValueError, match=err_msg):
         export_text(clf, feature_names=["a"])
     err_msg = (
-        "When `class_names` is a list, it should contain as"
+        "When `class_names` is an array, it should contain as"
         " many items as `decision_tree.classes_`. Got 1 while"
         " the tree was fitted with 2 classes."
     )


Code review comments:
diff_hunk: @@ -953,16 +953,16 @@ def export_text(
         It can be an instance of
         DecisionTreeClassifier or DecisionTreeRegressor.
 
-    feature_names : list of str, default=None
-        A list of length n_features containing the feature names.
+    feature_names : array-like of shape (n_features,), default=None
+        An array containing the feature names.
         If None generic names will be used ("feature_0", "feature_1", ...).
 
-    class_names : list or None, default=None
+    class_names : array-like or None, default=None
path: sklearn/tree/_export.py
text: @user1:
An "array-like" could be a custom object that only defines `__array__`:

```python
import numpy as np

class MyArrayLike:
    def __init__(self, ndarray):
        self._inner_ndarray = ndarray
        
    def __array__(self):
        return self._inner_ndarray
    
class_names = MyArrayLike(np.array(["a", "b"]))

try:
    len(classs_names)
except TypeError as e:
    print(e)
# object of type 'MyArrayLike' has no len()

try:
    classs_names[0]
except TypeError as e:
    print(e)
# 'MyArrayLike' object is not subscriptable


```

To support fully array-likes, we'll need to call `asarray` on it before operating on it:

```python
class_names = MyArrayLike(np.array(["a", "b"]))
class_names_array = np.asarray(class_names)

print(len(class_names_array))
print(class_names_array[0])
```

Note the downside of calling `asarray` is that it will make a copy when the input is a list.

### Alternative

`export_*` only requires the input object to define `__len__` and a  `__getitem__` that returns a string. This interface is provided by `list` and `ndarray`, but not custom objects that only define `__array__` to be "array-like". For this PR, I'll prefer to be strict here and accept "list" and "ndarary" explicit and not the generic "array-like". Concretely:

```
class_names : list, ndarray, or None, default=None
```

@author:
@user1 Thanks for the information! I will make these changes ASAP.