Skip to content

Commit 9b446d4

Browse files
authored
Merge pull request #27 from chcwww/hyperparameter
Add Hyperparameter Search Support for Tree-based Models
2 parents 5cd775a + 6262d1b commit 9b446d4

10 files changed

Lines changed: 627 additions & 49 deletions

File tree

docs/api/linear.rst

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,17 @@ The simplest usage is::
3434

3535
.. autofunction:: get_positive_labels
3636

37+
.. autofunction:: linear_test
38+
3739
.. autoclass:: FlatModel
3840
:members:
3941

4042
.. autoclass:: TreeModel
4143
:members:
4244

45+
.. autoclass:: EnsembleTreeModel
46+
:members:
47+
4348
Load Dataset
4449
^^^^^^^^^^^^
4550

@@ -101,3 +106,18 @@ Grid Search with Sklearn Estimators
101106
:members:
102107

103108
.. automethod:: __init__
109+
110+
Grid Search for Tree-Based Linear Method
111+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
112+
113+
.. autoclass:: TreeGridParameter
114+
:members:
115+
116+
.. automethod:: __init__
117+
118+
.. autoclass:: TreeGridSearch
119+
:members:
120+
121+
.. automethod:: __init__
122+
123+
.. automethod:: __call__

docs/examples/plot_linear_gridsearch_tutorial.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
"""
2-
Hyperparameter Search for Linear Methods
2+
Hyperparameter Search for One-vs-rest Linear Methods
33
=============================================================
4+
.. warning::
5+
6+
If you are using the tree-based linear method,
7+
please check `Hyperparameter Search for Tree-Based Linear Method <../auto_examples/plot_tree_gridsearch_tutorial.html>`_.
8+
49
This guide helps users to tune the hyperparameters of the feature generation step and the linear model.
10+
In this guide, the following methods are available:
11+
``1vsrest``, ``thresholding``, ``cost_sensitive``, ``cost_sensitive_micro``, and ``binary_and_multiclass``.
512
613
Here we show an example of tuning a linear text classifier with the `rcv1 dataset <https://www.csie.ntu.edu.tw/~cjlin/libsvmtools/datasets/multilabel.html#rcv1v2%20(topics;%20full%20sets)>`_.
714
Starting with loading and preprocessing of the data without using ``Preprocessor``:
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""
2+
Hyperparameter Search for Tree-Based Linear Method
3+
=============================================================
4+
.. warning::
5+
6+
If you are using the one-vs-rest linear methods,
7+
please check `Hyperparameter Search for One-vs-rest Linear Methods <../auto_examples/plot_linear_gridsearch_tutorial.html>`_.
8+
9+
To apply tree-based linear methods,
10+
we first convert raw text into numerical TF-IDF features.
11+
During training, the method builds a label tree and trains linear classifiers.
12+
At inference, the model traverses the tree and selects
13+
only a few candidate labels at each level to speed up prediction.
14+
15+
To improve model performance, we need to search the hyperparameter space.
16+
Therefore, in this guide, we help users tune the hyperparameters of the tree-based linear method.
17+
18+
.. seealso::
19+
20+
`Implementation Document <https://www.csie.ntu.edu.tw/~cjlin/papers/libmultilabel/libmultilabel_implementation.pdf>`_:
21+
For more details about the implementation of tree-based linear methods and hyperparameter search.
22+
23+
Here we show an example of tuning a tree-based linear text classifier with the `rcv1 dataset <https://www.csie.ntu.edu.tw/~cjlin/libsvmtools/datasets/multilabel.html#rcv1v2%20(topics;%20full%20sets)>`_.
24+
Starting with loading the data:
25+
"""
26+
27+
import logging
28+
29+
from libmultilabel import linear
30+
31+
logging.basicConfig(level=logging.INFO)
32+
33+
datasets = linear.load_dataset("txt", "data/rcv1/train.txt", "data/rcv1/test.txt")
34+
L = len(datasets["train"]["y"])
35+
36+
######################################################################
37+
# Next, we set up the search space.
38+
39+
import numpy as np
40+
41+
dmax = 10
42+
K_factors = [-2, 5]
43+
search_space_dict = {
44+
"ngram_range": [(1, 1), (1, 2), (1, 3)],
45+
"stop_words": ["english"],
46+
"dmax": [dmax],
47+
"K": [max(2, int(np.round(np.power(L, 1 / dmax) * np.power(2.0, alpha) + 0.5))) for alpha in K_factors],
48+
"s": [1],
49+
"c": [0.5, 1, 2],
50+
"B": [1],
51+
"beam_width": [10],
52+
"prob_A": [3]
53+
}
54+
55+
######################################################################
56+
# Following the suggestions in the `implementation document <https://www.csie.ntu.edu.tw/~cjlin/papers/libmultilabel/libmultilabel_implementation.pdf>`_,
57+
# we define 18 configurations to build a simple yet strong baseline.
58+
#
59+
# The search space covers several key parts of the search process:
60+
#
61+
# - Text feature extraction: (``ngram_range``, ``stop_words``)
62+
#
63+
# - We use the vectorizer ``TfidfVectorizer`` from ``sklearn`` to generate features from raw text.
64+
#
65+
# - Label tree structure: (``dmax``, ``K``)
66+
#
67+
# - The depth and node degree of the label tree. Note that ``K`` is the number of clusters and is calculated using the formula from the `implementation document <https://www.csie.ntu.edu.tw/~cjlin/papers/libmultilabel/libmultilabel_implementation.pdf>`_.
68+
#
69+
# - Linear classifier: (``s``, ``c``, ``B``)
70+
#
71+
# - We combined them into a LIBLINEAR option string for training linear classifiers. (see *train Usage* in `liblinear <https://github.com/cjlin1/liblinear>`__ README)
72+
#
73+
# - Prediction: (``beam_width``, ``prob_A``)
74+
#
75+
# - The number of candidates considered and the parameter for the probability estimation function at each level during prediction.
76+
#
77+
# .. tip::
78+
#
79+
# Available hyperparameters (and their defaults) are defined in the class variables of :py:class:`~libmultilabel.linear.TreeGridParameter`.
80+
#
81+
# In :py:class:`~libmultilabel.linear.TreeGridSearch`, we perform cross-validation for evaluation.
82+
# Specifically, we split the training data into ``n_folds``,
83+
# sequentially using each fold as the validation set while training on the remaining folds.
84+
# Finally, we aggregate the validation outputs from each fold and compute the ``monitor_metrics``.
85+
# Initialization requires the dataset, the number of cross-validation folds, and the evaluation metrics.
86+
87+
n_folds = 3
88+
monitor_metrics = ["P@1", "P@3", "P@5"]
89+
search = linear.TreeGridSearch(datasets, n_folds, monitor_metrics)
90+
cv_scores = search(search_space_dict)
91+
92+
######################################################################
93+
# ``cv_scores`` is a dictionary where keys are :py:class:`~libmultilabel.linear.TreeGridParameter` instances and values are the ``monitor_metrics`` results.
94+
#
95+
# Here we sort the results in descending order by the first metric in ``monitor_metrics``.
96+
# You can retrieve the best parameters after the grid search with the following code:
97+
98+
sorted_cv_scores = sorted(cv_scores.items(), key=lambda x: x[1][monitor_metrics[0]], reverse=True)
99+
print(sorted_cv_scores)
100+
101+
best_params, best_cv_scores = list(sorted_cv_scores)[0]
102+
print(best_params, best_cv_scores)
103+
104+
######################################################################
105+
# The best parameters are::
106+
#
107+
# {'ngram_range': (1, 3), 'stop_words': 'english', 'dmax': 10, 'K': 88, 's': 1, 'c': 1, 'B': 1, 'beam_width': 10, 'prob_A': 3}
108+
#
109+
# with best cross-validation scores::
110+
#
111+
# {'P@1': 0.9669, 'P@3': 0.8137, 'P@5': 0.5640}
112+
#
113+
# We can then retrain using the best parameters,
114+
# and use :py:meth:`~libmultilabel.linear.linear_test` and :py:meth:`~libmultilabel.linear.get_metrics` to compute test performance.
115+
116+
from dataclasses import asdict
117+
118+
preprocessor = linear.Preprocessor(tfidf_params=asdict(best_params.tfidf))
119+
transformed_dataset = preprocessor.fit_transform(datasets)
120+
121+
model = linear.train_tree(
122+
transformed_dataset["train"]["y"],
123+
transformed_dataset["train"]["x"],
124+
best_params.linear_options,
125+
**asdict(best_params.tree),
126+
)
127+
128+
metrics, _, _, _ = linear.linear_test(
129+
y = transformed_dataset["test"]["y"],
130+
x = transformed_dataset["test"]["x"],
131+
model = model,
132+
metrics = linear.get_metrics(monitor_metrics, num_classes=-1),
133+
predict_kwargs = asdict(best_params.predict),
134+
)
135+
136+
print(metrics.compute())
137+
138+
######################################################################
139+
# The result of the best parameters will look similar to::
140+
#
141+
# {'P@1': 0.9554, 'P@3': 0.7968, 'P@5': 0.5576}

docs/search_retrain.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ Hyperparameter Search
77

88

99
../auto_examples/plot_linear_gridsearch_tutorial
10+
../auto_examples/plot_tree_gridsearch_tutorial
1011
tutorials/Parameter_Selection_for_Neural_Networks

libmultilabel/linear/tree.py

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import numpy as np
66
import scipy.sparse as sparse
7+
from scipy.special import log_expit
78
from sparsekmeans import LloydKmeans, ElkanKmeans
89
import sklearn.preprocessing
910
from tqdm import tqdm
@@ -58,16 +59,35 @@ def __init__(
5859
self.multiclass = False
5960
self._model_separated = False # Indicates whether the model has been separated for pruning tree.
6061

62+
def sigmoid_A(self, x: np.ndarray, prob_A: int) -> np.ndarray:
63+
"""
64+
Calculate log(sigmoid(prob_A * x)), which represents the probability of the positive class in binary classification.
65+
66+
Args:
67+
x (np.ndarray): The decision value matrix with dimension number of instances * number of classes.
68+
prob_A (int):
69+
The hyperparameter used in the probability estimation function for
70+
binary classification: sigmoid(prob_A * x).
71+
72+
Returns:
73+
np.ndarray: A matrix with dimension number of instances * number of classes.
74+
"""
75+
return log_expit(prob_A * x)
76+
6177
def predict_values(
6278
self,
6379
x: sparse.csr_matrix,
6480
beam_width: int = 10,
81+
prob_A: int = 3,
6582
) -> np.ndarray:
6683
"""Calculate the probability estimates associated with x.
6784
6885
Args:
6986
x (sparse.csr_matrix): A matrix with dimension number of instances * number of features.
70-
beam_width (int, optional): Number of candidates considered during beam search. Defaults to 10.
87+
beam_width (int, optional): Number of candidates considered during beam search.
88+
prob_A (int, optional):
89+
The hyperparameter used in the probability estimation function for
90+
binary classification: sigmoid(prob_A * decision_value_matrix).
7191
7292
Returns:
7393
np.ndarray: A matrix with dimension number of instances * number of classes.
@@ -81,8 +101,8 @@ def predict_values(
81101
if not self._model_separated:
82102
self._separate_model_for_pruning_tree()
83103
self._model_separated = True
84-
all_preds = self._prune_tree_and_predict_values(x, beam_width) # number of instances * (number of labels + total number of metalabels)
85-
return np.vstack([self._beam_search(all_preds[i], beam_width) for i in range(all_preds.shape[0])])
104+
all_preds = self._prune_tree_and_predict_values(x, beam_width, prob_A) # number of instances * (number of labels + total number of metalabels)
105+
return np.vstack([self._beam_search(all_preds[i], beam_width, prob_A) for i in range(all_preds.shape[0])])
86106

87107
def _separate_model_for_pruning_tree(self):
88108
"""
@@ -113,7 +133,7 @@ def _separate_model_for_pruning_tree(self):
113133
)
114134
self.subtree_models.append(subtree_flatmodel)
115135

116-
def _prune_tree_and_predict_values(self, x: sparse.csr_matrix, beam_width: int) -> np.ndarray:
136+
def _prune_tree_and_predict_values(self, x: sparse.csr_matrix, beam_width: int, prob_A: int) -> np.ndarray:
117137
"""Calculates the selective decision values associated with instances x by evaluating only the most relevant subtrees.
118138
119139
Only subtrees corresponding to the top beam_width candidates from the root are evaluated,
@@ -122,6 +142,9 @@ def _prune_tree_and_predict_values(self, x: sparse.csr_matrix, beam_width: int)
122142
Args:
123143
x (sparse.csr_matrix): A matrix with dimension number of instances * number of features.
124144
beam_width (int): Number of top candidate branches considered for prediction.
145+
prob_A (int):
146+
The hyperparameter used in the probability estimation function for
147+
binary classification: sigmoid(prob_A * decision_value_matrix).
125148
126149
Returns:
127150
np.ndarray: A matrix with dimension number of instances * (number of labels + total number of metalabels).
@@ -132,7 +155,7 @@ def _prune_tree_and_predict_values(self, x: sparse.csr_matrix, beam_width: int)
132155

133156
# Calculate root decision values and scores
134157
root_preds = linear.predict_values(self.root_model, x)
135-
children_scores = 0.0 - np.square(np.maximum(0, 1 - root_preds))
158+
children_scores = 0.0 + self.sigmoid_A(root_preds, prob_A)
136159

137160
slice = np.s_[:, self.node_ptr[self.root.index] : self.node_ptr[self.root.index + 1]]
138161
all_preds[slice] = root_preds
@@ -159,12 +182,15 @@ def _prune_tree_and_predict_values(self, x: sparse.csr_matrix, beam_width: int)
159182

160183
return all_preds
161184

162-
def _beam_search(self, instance_preds: np.ndarray, beam_width: int) -> np.ndarray:
185+
def _beam_search(self, instance_preds: np.ndarray, beam_width: int, prob_A: int) -> np.ndarray:
163186
"""Predict with beam search using cached probability estimates for a single instance.
164187
165188
Args:
166189
instance_preds (np.ndarray): A vector of cached probability estimates of each node, has dimension number of labels + total number of metalabels.
167190
beam_width (int): Number of candidates considered.
191+
prob_A (int, optional):
192+
The hyperparameter used in the probability estimation function for
193+
binary classification: sigmoid(prob_A * decision_value_matrix).
168194
169195
Returns:
170196
np.ndarray: A vector with dimension number of classes.
@@ -182,7 +208,7 @@ def _beam_search(self, instance_preds: np.ndarray, beam_width: int) -> np.ndarra
182208
continue
183209
slice = np.s_[self.node_ptr[node.index] : self.node_ptr[node.index + 1]]
184210
pred = instance_preds[slice]
185-
children_score = score - np.square(np.maximum(0, 1 - pred))
211+
children_score = score + self.sigmoid_A(pred, prob_A)
186212
next_level.extend(zip(node.children, children_score.tolist()))
187213

188214
cur_level = sorted(next_level, key=lambda pair: -pair[1])[:beam_width]
@@ -193,7 +219,7 @@ def _beam_search(self, instance_preds: np.ndarray, beam_width: int) -> np.ndarra
193219
for node, score in cur_level:
194220
slice = np.s_[self.node_ptr[node.index] : self.node_ptr[node.index + 1]]
195221
pred = instance_preds[slice]
196-
scores[node.label_map] = np.exp(score - np.square(np.maximum(0, 1 - pred)))
222+
scores[node.label_map] = np.exp(score + self.sigmoid_A(pred, prob_A))
197223
return scores
198224

199225

@@ -204,6 +230,7 @@ def train_tree(
204230
K=DEFAULT_K,
205231
dmax=DEFAULT_DMAX,
206232
verbose: bool = True,
233+
root: Node = None,
207234
) -> TreeModel:
208235
"""Train a linear model for multi-label data using a divide-and-conquer strategy.
209236
The algorithm used is based on https://github.com/xmc-aalto/bonsai.
@@ -215,14 +242,16 @@ def train_tree(
215242
K (int, optional): Maximum degree of nodes in the tree. Defaults to 100.
216243
dmax (int, optional): Maximum depth of the tree. Defaults to 10.
217244
verbose (bool, optional): Output extra progress information. Defaults to True.
245+
root (Node, optional): Pre-built tree root. Defaults to None.
218246
219247
Returns:
220248
TreeModel: A model which can be used in predict_values.
221249
"""
222-
label_representation = (y.T * x).tocsr()
223-
label_representation = sklearn.preprocessing.normalize(label_representation, norm="l2", axis=1)
224-
root = _build_tree(label_representation, np.arange(y.shape[1]), 0, K, dmax)
225-
root.is_root = True
250+
if root is None:
251+
label_representation = (y.T * x).tocsr()
252+
label_representation = sklearn.preprocessing.normalize(label_representation, norm="l2", axis=1)
253+
root = _build_tree(label_representation, np.arange(y.shape[1]), 0, K, dmax)
254+
root.is_root = True
226255

227256
num_nodes = 0
228257
# Both type(x) and type(y) are sparse.csr_matrix

0 commit comments

Comments
 (0)