-
Notifications
You must be signed in to change notification settings - Fork 32
Expand file tree
/
Copy pathLocalClassifierPerNode.py
More file actions
281 lines (242 loc) · 11.5 KB
/
Copy pathLocalClassifierPerNode.py
File metadata and controls
281 lines (242 loc) · 11.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
"""
Local classifier per node approach.
Numeric and string output labels are both handled.
"""
import hashlib
import pickle
from copy import deepcopy
from os.path import exists
import networkx as nx
import numpy as np
from sklearn.base import BaseEstimator
from sklearn.utils.validation import check_array, check_is_fitted
from hiclass import BinaryPolicy
from hiclass.ConstantClassifier import ConstantClassifier
from hiclass.HierarchicalClassifier import HierarchicalClassifier
class LocalClassifierPerNode(BaseEstimator, HierarchicalClassifier):
"""
Assign local classifiers to each node of the graph, except the root node.
A local classifier per node is a local hierarchical classifier that fits one local binary classifier
for each node of the class hierarchy, except for the root node.
Examples
--------
>>> from hiclass import LocalClassifierPerNode
>>> y = [['1', '1.1'], ['2', '2.1']]
>>> X = [[1, 2], [3, 4]]
>>> lcpn = LocalClassifierPerNode()
>>> lcpn.fit(X, y)
>>> lcpn.predict(X)
array([['1', '1.1'],
['2', '2.1']])
"""
def __init__(
self,
local_classifier: BaseEstimator = None,
binary_policy: str = "siblings",
verbose: int = 0,
edge_list: str = None,
replace_classifiers: bool = True,
n_jobs: int = 1,
bert: bool = False,
tmp_dir: str = None,
warm_start: bool = False,
):
"""
Initialize a local classifier per node.
Parameters
----------
local_classifier : BaseEstimator, default=LogisticRegression
The local_classifier used to create the collection of local classifiers. Needs to have fit, predict and
clone methods.
binary_policy : {"exclusive", "less_exclusive", "exclusive_siblings", "inclusive", "less_inclusive", "siblings"}, str, default="siblings"
Specify the rule for defining positive and negative training examples, using one of the following options:
- `exclusive`: Positive examples belong only to the class being considered. All classes are negative examples, except for the selected class;
- `less_exclusive`: Positive examples belong only to the class being considered. All classes are negative examples, except for the selected class and its descendants;
- `exclusive_siblings`: Positive examples belong only to the class being considered. All sibling classes are negative examples;
- `inclusive`: Positive examples belong only to the class being considered and its descendants. All classes are negative examples, except for the selected class, its descendants and ancestors;
- `less_inclusive`: Positive examples belong only to the class being considered and its descendants. All classes are negative examples, except for the selected class and its descendants;
- `siblings`: Positive examples belong only to the class being considered and its descendants. All siblings and their descendant classes are negative examples.
See :ref:`Training Policies` for more information about the different policies.
verbose : int, default=0
Controls the verbosity when fitting and predicting.
See https://verboselogs.readthedocs.io/en/latest/readme.html#overview-of-logging-levels
for more information.
edge_list : str, default=None
Path to write the hierarchy built.
replace_classifiers : bool, default=True
Turns on (True) the replacement of a local classifier with a constant classifier when trained on only
a single unique class.
n_jobs : int, default=1
The number of jobs to run in parallel. Only :code:`fit` is parallelized.
If :code:`Ray` is installed it is used, otherwise it defaults to :code:`Joblib`.
bert : bool, default=False
If True, skip scikit-learn's checks and sample_weight passing for BERT.
tmp_dir : str, default=None
Temporary directory to persist local classifiers that are trained. If the job needs to be restarted,
it will skip the pre-trained local classifier found in the temporary directory.
warm_start : bool, default=False
When set to true, the hierarchical classifier reuses the solution of the previous call to fit, that is,
new classes can be added.
"""
super().__init__(
local_classifier=local_classifier,
verbose=verbose,
edge_list=edge_list,
replace_classifiers=replace_classifiers,
n_jobs=n_jobs,
classifier_abbreviation="LCPN",
bert=bert,
tmp_dir=tmp_dir,
warm_start=warm_start,
)
self.binary_policy = binary_policy
def fit(self, X, y, sample_weight=None):
"""
Fit a local classifier per node.
Parameters
----------
X : {array-like, sparse matrix} of shape (n_samples, n_features)
The training input samples. Internally, its dtype will be converted
to ``dtype=np.float32``. If a sparse matrix is provided, it will be
converted into a sparse ``csc_matrix``.
y : array-like of shape (n_samples, n_levels)
The target values, i.e., hierarchical class labels for classification.
sample_weight : array-like of shape (n_samples,), default=None
Array of weights that are assigned to individual samples.
If not provided, then each sample is given unit weight.
Returns
-------
self : object
Fitted estimator.
"""
# Execute common methods necessary before fitting
super()._pre_fit(X, y, sample_weight)
# Initialize policy
self._initialize_binary_policy()
# TODO: add partial_fit here if warm_start=True
# Fit local classifiers in DAG
super().fit(X, y)
# TODO: Store the classes seen during fit
# TODO: Add function to allow user to change local classifier
# TODO: Add parameter to receive hierarchy as parameter in constructor
# Return the classifier
return self
def predict(self, X):
"""
Predict classes for the given data.
Hierarchical labels are returned.
Parameters
----------
X : {array-like, sparse matrix} of shape (n_samples, n_features)
The input samples. Internally, its dtype will be converted
to ``dtype=np.float32``. If a sparse matrix is provided, it will be
converted into a sparse ``csr_matrix``.
Returns
-------
y : ndarray of shape (n_samples,) or (n_samples, n_outputs)
The predicted classes.
"""
# Check if fit has been called
check_is_fitted(self)
# Input validation
if not self.bert:
X = check_array(X, accept_sparse="csr", allow_nd=True, ensure_2d=False)
else:
X = np.array(X)
# Initialize array that holds predictions
y = np.empty((X.shape[0], self.max_levels_), dtype=self.dtype_)
# TODO: Add threshold to stop prediction halfway if need be
bfs = nx.bfs_successors(self.hierarchy_, source=self.root_)
self.logger_.info("Predicting")
for predecessor, successors in bfs:
if predecessor == self.root_:
mask = [True] * X.shape[0]
subset_x = X[mask]
else:
mask = np.isin(y, predecessor).any(axis=1)
subset_x = X[mask]
if subset_x.shape[0] > 0:
probabilities = np.zeros((subset_x.shape[0], len(successors)))
for i, successor in enumerate(successors):
successor_name = str(successor).split(self.separator_)[-1]
self.logger_.info(f"Predicting for node '{successor_name}'")
classifier = self.hierarchy_.nodes[successor]["classifier"]
positive_index = np.where(classifier.classes_ == 1)[0]
probabilities[:, i] = classifier.predict_proba(subset_x)[
:, positive_index
][:, 0]
highest_probability = np.argmax(probabilities, axis=1)
prediction = []
for i in highest_probability:
prediction.append(successors[i])
level = nx.shortest_path_length(
self.hierarchy_, self.root_, predecessor
)
prediction = np.array(prediction)
y[mask, level] = prediction
y = self._convert_to_1d(y)
self._remove_separator(y)
return y
def _initialize_binary_policy(self):
if isinstance(self.binary_policy, str):
self.logger_.info(f"Initializing {self.binary_policy} binary policy")
try:
self.binary_policy_ = BinaryPolicy.IMPLEMENTED_POLICIES[
self.binary_policy.lower()
](self.hierarchy_, self.X_, self.y_, self.sample_weight_)
except KeyError:
self.logger_.error(
f"Policy {self.binary_policy} not implemented. Available policies are:\n"
+ f"{list(BinaryPolicy.IMPLEMENTED_POLICIES.keys())}"
)
raise KeyError(f"Policy {self.binary_policy} not implemented.")
else:
self.logger_.error("Binary policy is not a string")
raise ValueError(
f"Binary policy type must str, not {type(self.binary_policy)}."
)
def _initialize_local_classifiers(self):
super()._initialize_local_classifiers()
local_classifiers = {}
for node in self.hierarchy_.nodes:
# Skip only root node
if node != self.root_:
local_classifiers[node] = {
"classifier": deepcopy(self.local_classifier_)
}
nx.set_node_attributes(self.hierarchy_, local_classifiers)
def _fit_digraph(self, local_mode: bool = False, use_joblib: bool = False):
self.logger_.info("Fitting local classifiers")
nodes = list(self.hierarchy_.nodes)
# Remove root because it does not need to be fitted
nodes.remove(self.root_)
self._fit_node_classifier(nodes, local_mode, use_joblib)
@staticmethod
def _fit_classifier(self, node):
classifier = self.hierarchy_.nodes[node]["classifier"]
if self.tmp_dir:
md5 = hashlib.md5(node.encode("utf-8")).hexdigest()
filename = f"{self.tmp_dir}/{md5}.sav"
if exists(filename):
(_, classifier) = pickle.load(open(filename, "rb"))
self.logger_.info(
f"Loaded trained model for local classifier {node.split(self.separator_)[-1]} from file {filename}"
)
return classifier
self.logger_.info(f"Training local classifier {node}")
X, y, sample_weight = self.binary_policy_.get_binary_examples(node)
unique_y = np.unique(y)
if len(unique_y) == 1 and self.replace_classifiers:
classifier = ConstantClassifier()
if not self.bert:
try:
classifier.fit(X, y, sample_weight)
except TypeError:
classifier.fit(X, y)
else:
classifier.fit(X, y)
self._save_tmp(node, classifier)
return classifier
def _clean_up(self):
super()._clean_up()
del self.binary_policy_