chaid-segmenter 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chaid_segmenter-0.1.0/CHAID/__init__.py +8 -0
- chaid_segmenter-0.1.0/CHAID/__main__.py +110 -0
- chaid_segmenter-0.1.0/CHAID/column.py +333 -0
- chaid_segmenter-0.1.0/CHAID/graph.py +124 -0
- chaid_segmenter-0.1.0/CHAID/invalid_split_reason.py +18 -0
- chaid_segmenter-0.1.0/CHAID/mapping_dict.py +5 -0
- chaid_segmenter-0.1.0/CHAID/node.py +122 -0
- chaid_segmenter-0.1.0/CHAID/split.py +90 -0
- chaid_segmenter-0.1.0/CHAID/stats.py +264 -0
- chaid_segmenter-0.1.0/CHAID/tree.py +316 -0
- chaid_segmenter-0.1.0/LICENSE.txt +202 -0
- chaid_segmenter-0.1.0/MANIFEST.in +3 -0
- chaid_segmenter-0.1.0/NOTICE +17 -0
- chaid_segmenter-0.1.0/PKG-INFO +324 -0
- chaid_segmenter-0.1.0/README.md +289 -0
- chaid_segmenter-0.1.0/chaid_segmenter/__init__.py +37 -0
- chaid_segmenter-0.1.0/chaid_segmenter/binning.py +407 -0
- chaid_segmenter-0.1.0/chaid_segmenter/layout.py +64 -0
- chaid_segmenter-0.1.0/chaid_segmenter/metrics.py +55 -0
- chaid_segmenter-0.1.0/chaid_segmenter/plotting.py +188 -0
- chaid_segmenter-0.1.0/chaid_segmenter/rules.py +61 -0
- chaid_segmenter-0.1.0/chaid_segmenter/segmenter.py +397 -0
- chaid_segmenter-0.1.0/chaid_segmenter.egg-info/PKG-INFO +324 -0
- chaid_segmenter-0.1.0/chaid_segmenter.egg-info/SOURCES.txt +46 -0
- chaid_segmenter-0.1.0/chaid_segmenter.egg-info/dependency_links.txt +1 -0
- chaid_segmenter-0.1.0/chaid_segmenter.egg-info/requires.txt +6 -0
- chaid_segmenter-0.1.0/chaid_segmenter.egg-info/top_level.txt +2 -0
- chaid_segmenter-0.1.0/pyproject.toml +40 -0
- chaid_segmenter-0.1.0/setup.cfg +7 -0
- chaid_segmenter-0.1.0/setup.py +75 -0
- chaid_segmenter-0.1.0/tests/test_continuous_column.py +32 -0
- chaid_segmenter-0.1.0/tests/test_graph.py +35 -0
- chaid_segmenter-0.1.0/tests/test_graph_optional_import.py +46 -0
- chaid_segmenter-0.1.0/tests/test_node.py +25 -0
- chaid_segmenter-0.1.0/tests/test_nominal_column.py +179 -0
- chaid_segmenter-0.1.0/tests/test_ordinal_column.py +298 -0
- chaid_segmenter-0.1.0/tests/test_segmenter_binary.py +90 -0
- chaid_segmenter-0.1.0/tests/test_segmenter_binning.py +109 -0
- chaid_segmenter-0.1.0/tests/test_segmenter_continuous.py +36 -0
- chaid_segmenter-0.1.0/tests/test_segmenter_highcard.py +99 -0
- chaid_segmenter-0.1.0/tests/test_segmenter_inference.py +85 -0
- chaid_segmenter-0.1.0/tests/test_segmenter_loaders.py +32 -0
- chaid_segmenter-0.1.0/tests/test_segmenter_plotting.py +66 -0
- chaid_segmenter-0.1.0/tests/test_segmenter_rules.py +39 -0
- chaid_segmenter-0.1.0/tests/test_split.py +31 -0
- chaid_segmenter-0.1.0/tests/test_stats.py +150 -0
- chaid_segmenter-0.1.0/tests/test_tree.py +748 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This package provides a python implementation of the Chi-Squared Automatic
|
|
3
|
+
Inference Detection (CHAID) decision tree.
|
|
4
|
+
"""
|
|
5
|
+
import argparse
|
|
6
|
+
from .tree import Tree
|
|
7
|
+
import pandas as pd
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main():
|
|
12
|
+
"""Entry point when module is run from command line"""
|
|
13
|
+
|
|
14
|
+
parser = argparse.ArgumentParser(description='Run the chaid algorithm on a'
|
|
15
|
+
' csv/sav file.')
|
|
16
|
+
parser.add_argument('file')
|
|
17
|
+
parser.add_argument('dependent_variable', nargs=1)
|
|
18
|
+
parser.add_argument('--dependent-variable-type', type=str)
|
|
19
|
+
|
|
20
|
+
var = parser.add_argument_group('Independent Variable Specification')
|
|
21
|
+
var.add_argument('nominal_variables', nargs='*', help='The names of '
|
|
22
|
+
'independent variables to use that have no intrinsic '
|
|
23
|
+
'order to them')
|
|
24
|
+
var.add_argument('--ordinal-variables', type=str, nargs='*',
|
|
25
|
+
help='The names of independent variables to use that '
|
|
26
|
+
'have an intrinsic order but a finite amount of states')
|
|
27
|
+
parser.add_argument('--weights', type=str, help='Name of weight column')
|
|
28
|
+
|
|
29
|
+
parser.add_argument('--max-depth', type=int, help='Max depth of generated '
|
|
30
|
+
'tree')
|
|
31
|
+
parser.add_argument('--min-parent-node-size', type=int, help='Minimum number of '
|
|
32
|
+
'samples required to split the parent node')
|
|
33
|
+
parser.add_argument('--min-child-node-size', type=int, help='Minimum number of '
|
|
34
|
+
'samples required to split the child node')
|
|
35
|
+
parser.add_argument('--alpha-merge', type=float, help='Alpha Merge')
|
|
36
|
+
group = parser.add_mutually_exclusive_group(required=False)
|
|
37
|
+
group.add_argument('--classify', action='store_true', help='Add column to'
|
|
38
|
+
' input with the node id of the node that that '
|
|
39
|
+
'respondent has been placed into')
|
|
40
|
+
group.add_argument('--predict', action='store_true', help='Add column to '
|
|
41
|
+
'input with the value of the dependent variable that '
|
|
42
|
+
'the majority of respondents in that node selected')
|
|
43
|
+
group.add_argument('--rules', action='store_true')
|
|
44
|
+
group.add_argument('--export', action='store_true', help='Whether to export the chart to pdf/dot')
|
|
45
|
+
group.add_argument('--export-path', type=str, help='Path to store chart output')
|
|
46
|
+
|
|
47
|
+
group.add_argument('--exhaustive', action='store_true', help='To implement exhustive CHAID')
|
|
48
|
+
|
|
49
|
+
nspace = parser.parse_args()
|
|
50
|
+
|
|
51
|
+
if nspace.file[-4:] == '.csv':
|
|
52
|
+
data = pd.read_csv(nspace.file)
|
|
53
|
+
elif nspace.file[-4:] == '.sav':
|
|
54
|
+
import savReaderWriter as spss
|
|
55
|
+
raw_data = spss.SavReader(nspace.file, returnHeader=True)
|
|
56
|
+
raw_data_list = list(raw_data)
|
|
57
|
+
data = pd.DataFrame(raw_data_list)
|
|
58
|
+
data = data.rename(columns=data.loc[0]).iloc[1:]
|
|
59
|
+
else:
|
|
60
|
+
print('Unknown file type')
|
|
61
|
+
exit(1)
|
|
62
|
+
|
|
63
|
+
config = {}
|
|
64
|
+
if nspace.max_depth:
|
|
65
|
+
config['max_depth'] = nspace.max_depth
|
|
66
|
+
if nspace.alpha_merge:
|
|
67
|
+
config['alpha_merge'] = nspace.alpha_merge
|
|
68
|
+
if nspace.min_parent_node_size:
|
|
69
|
+
config['min_parent_node_size'] = nspace.min_parent_node_size
|
|
70
|
+
if nspace.min_child_node_size:
|
|
71
|
+
config['min_child_node_size'] = nspace.min_child_node_size
|
|
72
|
+
if nspace.weights:
|
|
73
|
+
config['weight'] = nspace.weights
|
|
74
|
+
if nspace.dependent_variable_type:
|
|
75
|
+
config['dep_variable_type'] = nspace.dependent_variable_type
|
|
76
|
+
if nspace.exhaustive:
|
|
77
|
+
config['is_exhaustive'] = nspace.exhaustive
|
|
78
|
+
|
|
79
|
+
ordinal = nspace.ordinal_variables or []
|
|
80
|
+
nominal = nspace.nominal_variables or []
|
|
81
|
+
independent_variables = nominal + ordinal
|
|
82
|
+
types = dict(zip(nominal + ordinal, ['nominal'] * len(nominal) + ['ordinal'] * len(ordinal)))
|
|
83
|
+
if len(independent_variables) == 0:
|
|
84
|
+
print('Need to provide at least one independent variable')
|
|
85
|
+
exit(1)
|
|
86
|
+
tree = Tree.from_pandas_df(data, types, nspace.dependent_variable[0],
|
|
87
|
+
**config)
|
|
88
|
+
|
|
89
|
+
if nspace.export or nspace.export_path:
|
|
90
|
+
tree.render(nspace.export_path, True)
|
|
91
|
+
|
|
92
|
+
if nspace.classify:
|
|
93
|
+
predictions = pd.Series(tree.node_predictions())
|
|
94
|
+
predictions.name = 'node_id'
|
|
95
|
+
data = pd.concat([data, predictions], axis=1)
|
|
96
|
+
print(data.to_csv())
|
|
97
|
+
elif nspace.predict:
|
|
98
|
+
predictions = pd.Series(tree.model_predictions())
|
|
99
|
+
predictions.name = 'predicted'
|
|
100
|
+
data = pd.concat([data, predictions], axis=1)
|
|
101
|
+
print(data.to_csv())
|
|
102
|
+
elif nspace.rules:
|
|
103
|
+
print('\n'.join(str(x) for x in tree.classification_rules()))
|
|
104
|
+
else:
|
|
105
|
+
tree.print_tree()
|
|
106
|
+
print('Accuracy: ', tree.accuracy())
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
main()
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from math import isnan
|
|
3
|
+
from itertools import combinations
|
|
4
|
+
from .mapping_dict import MappingDict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def convert_to_python_type(value):
|
|
8
|
+
"""
|
|
9
|
+
Convert numpy scalar types to Python native types.
|
|
10
|
+
This ensures compatibility with numpy >= 2.0.0 where numpy scalars
|
|
11
|
+
are preserved in operations like np.unique().
|
|
12
|
+
|
|
13
|
+
Parameters
|
|
14
|
+
----------
|
|
15
|
+
value : any
|
|
16
|
+
The value to convert (may be a numpy scalar or Python native type)
|
|
17
|
+
|
|
18
|
+
Returns
|
|
19
|
+
-------
|
|
20
|
+
The value converted to a Python native type if it was a numpy scalar,
|
|
21
|
+
otherwise the original value
|
|
22
|
+
"""
|
|
23
|
+
if hasattr(value, 'item'):
|
|
24
|
+
# numpy scalars have an item() method that returns the Python scalar
|
|
25
|
+
return value.item()
|
|
26
|
+
return value
|
|
27
|
+
|
|
28
|
+
def is_sorted(ndarr, nan_val=None):
|
|
29
|
+
store = []
|
|
30
|
+
for arr in ndarr:
|
|
31
|
+
if arr == [] or len(arr) == 1: continue
|
|
32
|
+
if nan_val is not None and nan_val in arr:
|
|
33
|
+
arr.remove(nan_val)
|
|
34
|
+
store.append(arr[-1] - arr[0] == len(arr) - 1)
|
|
35
|
+
return all(store)
|
|
36
|
+
|
|
37
|
+
class Column(object):
|
|
38
|
+
"""
|
|
39
|
+
A numpy array with metadata
|
|
40
|
+
|
|
41
|
+
Parameters
|
|
42
|
+
----------
|
|
43
|
+
arr : iterable object
|
|
44
|
+
The numpy array
|
|
45
|
+
metadata : dict
|
|
46
|
+
The substitutions of the vector
|
|
47
|
+
missing_id : string
|
|
48
|
+
An identifier for the missing value to be associated
|
|
49
|
+
substitute : bool
|
|
50
|
+
Whether the objects in the given array need to be substitued for
|
|
51
|
+
integers
|
|
52
|
+
"""
|
|
53
|
+
def __init__(self, arr=None, metadata=None, missing_id='<missing>',
|
|
54
|
+
substitute=True, weights=None, name=None):
|
|
55
|
+
self.metadata = dict(metadata or {})
|
|
56
|
+
self.arr = np.array(arr)
|
|
57
|
+
self._missing_id = missing_id
|
|
58
|
+
self.weights = weights
|
|
59
|
+
self.name = name
|
|
60
|
+
|
|
61
|
+
def __iter__(self):
|
|
62
|
+
return iter(self.arr)
|
|
63
|
+
|
|
64
|
+
def __getitem__(self, key):
|
|
65
|
+
raise NotImplementedError
|
|
66
|
+
|
|
67
|
+
def __setitem__(self, key, value):
|
|
68
|
+
raise NotImplementedError
|
|
69
|
+
|
|
70
|
+
def possible_groupings(self):
|
|
71
|
+
raise NotImplementedError
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def type(self):
|
|
75
|
+
"""
|
|
76
|
+
Returns a string representing the type
|
|
77
|
+
"""
|
|
78
|
+
raise NotImplementedError
|
|
79
|
+
|
|
80
|
+
def deep_copy(self):
|
|
81
|
+
"""
|
|
82
|
+
Returns a deep copy
|
|
83
|
+
"""
|
|
84
|
+
raise NotImplementedError
|
|
85
|
+
|
|
86
|
+
def bell_set(self, collection, ordinal=False):
|
|
87
|
+
"""
|
|
88
|
+
Calculates the Bell set
|
|
89
|
+
"""
|
|
90
|
+
if len(collection) == 1:
|
|
91
|
+
yield [ collection ]
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
first = collection[0]
|
|
95
|
+
for smaller in self.bell_set(collection[1:]):
|
|
96
|
+
for n, subset in enumerate(smaller):
|
|
97
|
+
if not ordinal or (ordinal and is_sorted(smaller[:n] + [[ first ] + subset] + smaller[n+1:], self._nan)):
|
|
98
|
+
yield smaller[:n] + [[ first ] + subset] + smaller[n+1:]
|
|
99
|
+
|
|
100
|
+
if not ordinal or (ordinal and is_sorted([ [ first ] ] + smaller, self._nan)):
|
|
101
|
+
yield [ [ first ] ] + smaller
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class NominalColumn(Column):
|
|
105
|
+
"""
|
|
106
|
+
A column containing numerical values that are unrelated to
|
|
107
|
+
one another (i.e. do not follow a progression)
|
|
108
|
+
"""
|
|
109
|
+
def __init__(self, arr=None, metadata=None, missing_id='<missing>',
|
|
110
|
+
substitute=True, weights=None, name=None):
|
|
111
|
+
super(self.__class__, self).__init__(arr, metadata=metadata, missing_id=missing_id, weights=weights, name=name)
|
|
112
|
+
if substitute and metadata is None:
|
|
113
|
+
self.substitute_values(arr)
|
|
114
|
+
|
|
115
|
+
self._groupings = MappingDict()
|
|
116
|
+
for x in np.unique(self.arr):
|
|
117
|
+
self._groupings[x] = [x]
|
|
118
|
+
|
|
119
|
+
def deep_copy(self):
|
|
120
|
+
"""
|
|
121
|
+
Returns a deep copy.
|
|
122
|
+
"""
|
|
123
|
+
return NominalColumn(self.arr, metadata=self.metadata, name=self.name,
|
|
124
|
+
missing_id=self._missing_id, substitute=False, weights=self.weights)
|
|
125
|
+
|
|
126
|
+
def substitute_values(self, vect):
|
|
127
|
+
"""
|
|
128
|
+
Internal method to substitute integers into the vector, and construct
|
|
129
|
+
metadata to convert back to the original vector.
|
|
130
|
+
|
|
131
|
+
np.nan is always given -1, all other objects are given integers in
|
|
132
|
+
order of apperence.
|
|
133
|
+
|
|
134
|
+
Parameters
|
|
135
|
+
----------
|
|
136
|
+
vect : np.array
|
|
137
|
+
the vector in which to substitute values in
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
unique = np.unique(vect)
|
|
142
|
+
except:
|
|
143
|
+
unique = set(vect)
|
|
144
|
+
|
|
145
|
+
unique = [
|
|
146
|
+
x for x in unique if not isinstance(x, float) or not isnan(x)
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
arr = np.copy(vect)
|
|
150
|
+
for new_id, value in enumerate(unique):
|
|
151
|
+
np.place(arr, arr==value, new_id)
|
|
152
|
+
# Convert value to Python native type for numpy 2.0 compatibility
|
|
153
|
+
self.metadata[new_id] = convert_to_python_type(value)
|
|
154
|
+
arr = arr.astype(np.float64)
|
|
155
|
+
np.place(arr, np.isnan(arr), -1)
|
|
156
|
+
self.arr = arr
|
|
157
|
+
|
|
158
|
+
if -1 in arr:
|
|
159
|
+
self.metadata[-1] = self._missing_id
|
|
160
|
+
|
|
161
|
+
def __getitem__(self, key):
|
|
162
|
+
new_weights = None if self.weights is None else self.weights[key]
|
|
163
|
+
return NominalColumn(self.arr[key], metadata=self.metadata, substitute=False, weights=new_weights, name=self.name)
|
|
164
|
+
|
|
165
|
+
def __setitem__(self, key, value):
|
|
166
|
+
self.arr[key] = value
|
|
167
|
+
return self
|
|
168
|
+
|
|
169
|
+
def groups(self):
|
|
170
|
+
# Convert all values in groups to Python native types for numpy 2.0 compatibility
|
|
171
|
+
return [[convert_to_python_type(item) for item in group] for group in self._groupings.values()]
|
|
172
|
+
|
|
173
|
+
def possible_groupings(self):
|
|
174
|
+
return combinations(self._groupings.keys(), 2)
|
|
175
|
+
|
|
176
|
+
def all_combinations(self):
|
|
177
|
+
bell_set = self.bell_set(sorted(list(self._groupings.keys())))
|
|
178
|
+
next(bell_set)
|
|
179
|
+
return bell_set
|
|
180
|
+
|
|
181
|
+
def group(self, x, y):
|
|
182
|
+
self._groupings[x] += self._groupings[y]
|
|
183
|
+
del self._groupings[y]
|
|
184
|
+
self.arr[self.arr == y] = x
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def type(self):
|
|
188
|
+
"""
|
|
189
|
+
Returns a string representing the type
|
|
190
|
+
"""
|
|
191
|
+
return 'nominal'
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class OrdinalColumn(Column):
|
|
195
|
+
"""
|
|
196
|
+
A column containing integer values that have an order
|
|
197
|
+
"""
|
|
198
|
+
def __init__(self, arr=None, metadata=None, missing_id='<missing>',
|
|
199
|
+
groupings=None, substitute=True, weights=None, name=None):
|
|
200
|
+
super(self.__class__, self).__init__(arr, metadata, missing_id=missing_id, weights=weights, name=name)
|
|
201
|
+
self._nan = np.iinfo(np.int64).min
|
|
202
|
+
|
|
203
|
+
if substitute and metadata is None:
|
|
204
|
+
self.arr, self.orig_type = self.substitute_values(self.arr)
|
|
205
|
+
elif substitute and metadata and not np.issubdtype(self.arr.dtype, np.integer):
|
|
206
|
+
# custom metadata has been passed in from external source, and must be converted to int
|
|
207
|
+
self.arr = self.arr.astype(int)
|
|
208
|
+
self.metadata = { int(k):v for k, v in metadata.items() }
|
|
209
|
+
self.metadata[self._nan] = missing_id
|
|
210
|
+
|
|
211
|
+
self._groupings = {}
|
|
212
|
+
if groupings is None:
|
|
213
|
+
for x in np.unique(self.arr):
|
|
214
|
+
self._groupings[x] = [x, x + 1, False]
|
|
215
|
+
else:
|
|
216
|
+
for x in np.unique(self.arr):
|
|
217
|
+
self._groupings[x] = list(groupings[x])
|
|
218
|
+
self._possible_groups = None
|
|
219
|
+
|
|
220
|
+
def substitute_values(self, vect):
|
|
221
|
+
if not np.issubdtype(vect.dtype, np.integer):
|
|
222
|
+
uniq = set(vect)
|
|
223
|
+
uniq_floats = np.array(list(uniq), dtype=float)
|
|
224
|
+
uniq_ints = uniq_floats.astype(int)
|
|
225
|
+
nan = self._missing_id
|
|
226
|
+
self.metadata = {
|
|
227
|
+
new: nan if isnan(as_float) else old
|
|
228
|
+
for old, as_float, new in zip(uniq, uniq_floats, uniq_ints)
|
|
229
|
+
}
|
|
230
|
+
self.arr = self.arr.astype(float)
|
|
231
|
+
return self.arr.astype(int), self.arr.dtype.type
|
|
232
|
+
|
|
233
|
+
def deep_copy(self):
|
|
234
|
+
"""
|
|
235
|
+
Returns a deep copy.
|
|
236
|
+
"""
|
|
237
|
+
return OrdinalColumn(self.arr, metadata=self.metadata, name=self.name,
|
|
238
|
+
missing_id=self._missing_id, substitute=True,
|
|
239
|
+
groupings=self._groupings, weights=self.weights)
|
|
240
|
+
|
|
241
|
+
def __getitem__(self, key):
|
|
242
|
+
new_weights = None if self.weights is None else self.weights[key]
|
|
243
|
+
return OrdinalColumn(self.arr[key], metadata=self.metadata, name=self.name,
|
|
244
|
+
missing_id=self._missing_id, substitute=True,
|
|
245
|
+
groupings=self._groupings, weights=new_weights)
|
|
246
|
+
|
|
247
|
+
def __setitem__(self, key, value):
|
|
248
|
+
self.arr[key] = value
|
|
249
|
+
return self
|
|
250
|
+
|
|
251
|
+
def groups(self):
|
|
252
|
+
vals = self._groupings.values()
|
|
253
|
+
return [
|
|
254
|
+
[convert_to_python_type(x) for x in range(minmax[0], minmax[1])] + ([convert_to_python_type(self._nan)] if minmax[2] else [])
|
|
255
|
+
for minmax in vals
|
|
256
|
+
]
|
|
257
|
+
|
|
258
|
+
def possible_groupings(self):
|
|
259
|
+
if self._possible_groups is None:
|
|
260
|
+
ranges = sorted(self._groupings.items())
|
|
261
|
+
candidates = zip(ranges[0:], ranges[1:])
|
|
262
|
+
self._possible_groups = [
|
|
263
|
+
(k1, k2) for (k1, minmax1), (k2, minmax2) in candidates
|
|
264
|
+
if minmax1[1] == minmax2[0]
|
|
265
|
+
]
|
|
266
|
+
if self._nan in self.arr:
|
|
267
|
+
self._possible_groups += [
|
|
268
|
+
(key, self._nan) for key in self._groupings.keys() if key != self._nan
|
|
269
|
+
]
|
|
270
|
+
return self._possible_groups.__iter__()
|
|
271
|
+
|
|
272
|
+
def all_combinations(self):
|
|
273
|
+
bell_set = self.bell_set(sorted(list(self._groupings.keys())), True)
|
|
274
|
+
next(bell_set)
|
|
275
|
+
return bell_set
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def group(self, x, y):
|
|
279
|
+
self._possible_groups = None
|
|
280
|
+
if y != self._nan:
|
|
281
|
+
x = int(x)
|
|
282
|
+
y = int(y)
|
|
283
|
+
x_max = self._groupings[x][1]
|
|
284
|
+
y_min = self._groupings[y][0]
|
|
285
|
+
if y_min >= x_max:
|
|
286
|
+
self._groupings[x][1] = self._groupings[y][1]
|
|
287
|
+
else:
|
|
288
|
+
self._groupings[x][0] = y_min
|
|
289
|
+
self._groupings[x][2] = self._groupings[x][2] or self._groupings[y][2]
|
|
290
|
+
else:
|
|
291
|
+
self._groupings[x][2] = True
|
|
292
|
+
|
|
293
|
+
del self._groupings[y]
|
|
294
|
+
self.arr[self.arr == y] = x
|
|
295
|
+
|
|
296
|
+
@property
|
|
297
|
+
def type(self):
|
|
298
|
+
"""
|
|
299
|
+
Returns a string representing the type
|
|
300
|
+
"""
|
|
301
|
+
return 'ordinal'
|
|
302
|
+
|
|
303
|
+
class ContinuousColumn(Column):
|
|
304
|
+
"""
|
|
305
|
+
A column containing numerical values on a continuous scale
|
|
306
|
+
"""
|
|
307
|
+
def __init__(self, arr=None, metadata=None, missing_id='<missing>',
|
|
308
|
+
weights=None):
|
|
309
|
+
if not np.issubdtype(arr.dtype, np.number):
|
|
310
|
+
raise ValueError('Must only pass numerical values to create continuous column')
|
|
311
|
+
|
|
312
|
+
super(self.__class__, self).__init__(np.nan_to_num(arr), metadata, missing_id=missing_id, weights=weights)
|
|
313
|
+
|
|
314
|
+
def deep_copy(self):
|
|
315
|
+
"""
|
|
316
|
+
Returns a deep copy.
|
|
317
|
+
"""
|
|
318
|
+
return ContinuousColumn(self.arr, metadata=self.metadata, missing_id=self._missing_id, weights=self.weights)
|
|
319
|
+
|
|
320
|
+
def __getitem__(self, key):
|
|
321
|
+
new_weights = None if self.weights is None else self.weights[key]
|
|
322
|
+
return ContinuousColumn(self.arr[key], metadata=self.metadata, weights=new_weights)
|
|
323
|
+
|
|
324
|
+
def __setitem__(self, key, value):
|
|
325
|
+
self.arr[key] = value
|
|
326
|
+
return self
|
|
327
|
+
|
|
328
|
+
@property
|
|
329
|
+
def type(self):
|
|
330
|
+
"""
|
|
331
|
+
Returns a string representing the type
|
|
332
|
+
"""
|
|
333
|
+
return 'continuous'
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import warnings
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
# Wrap all the optional imports
|
|
6
|
+
try:
|
|
7
|
+
import plotly.graph_objs as go
|
|
8
|
+
import plotly.io as pio
|
|
9
|
+
import colorlover as cl
|
|
10
|
+
from graphviz import Digraph
|
|
11
|
+
|
|
12
|
+
except ImportError:
|
|
13
|
+
warnings.warn(UserWarning('Imports of optional packages needed to generate graphs failed. Please install with the "graph" option.'))
|
|
14
|
+
go = None
|
|
15
|
+
pio = None
|
|
16
|
+
cl = None
|
|
17
|
+
Digraph = None
|
|
18
|
+
|
|
19
|
+
FIG_BASE = {}
|
|
20
|
+
FIG_BASE_DATA = {}
|
|
21
|
+
TABLE_HEADER = []
|
|
22
|
+
TABLE_CONFIG = {}
|
|
23
|
+
TABLE_CELLS_CONFIG = {}
|
|
24
|
+
|
|
25
|
+
else:
|
|
26
|
+
FIG_BASE = {
|
|
27
|
+
"layout": {
|
|
28
|
+
"margin_t": 50,
|
|
29
|
+
"annotations": [{"font_size": 18, "x": 0.5, "y": 0.5}],
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
FIG_BASE_DATA = {
|
|
33
|
+
"domain": {"x": [0, 1], "y": [0.4, 1.0]},
|
|
34
|
+
"hole": 0.4,
|
|
35
|
+
"type": "pie",
|
|
36
|
+
"marker_colors": cl.scales["5"]["qual"]["Set1"],
|
|
37
|
+
}
|
|
38
|
+
TABLE_HEADER = ["<i>p</i>", "score", "splitting on"]
|
|
39
|
+
TABLE_CONFIG = {
|
|
40
|
+
"domain": {"x": [0.3, 0.7], "y": [0, 0.37]},
|
|
41
|
+
"header": {"fill_color": "#FFF"},
|
|
42
|
+
}
|
|
43
|
+
TABLE_CELLS_CONFIG = {
|
|
44
|
+
"line_color": "#FFF",
|
|
45
|
+
"align": "left",
|
|
46
|
+
"font_color": "#282828",
|
|
47
|
+
"height": 27,
|
|
48
|
+
"fill_color": ["#EBC1EE", "#EDEAFB"],
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
# Python 3.2 and newer
|
|
53
|
+
from tempfile import TemporaryDirectory
|
|
54
|
+
except ImportError:
|
|
55
|
+
# minimal backport of TemporaryDirectory for Python 2.7, sufficient
|
|
56
|
+
# for use with this module.
|
|
57
|
+
import shutil
|
|
58
|
+
from tempfile import mkdtemp
|
|
59
|
+
class TemporaryDirectory(object):
|
|
60
|
+
def __init__(self):
|
|
61
|
+
self.name = mkdtemp()
|
|
62
|
+
def __enter__(self):
|
|
63
|
+
return self.name
|
|
64
|
+
def __exit__(self, *args):
|
|
65
|
+
shutil.rmtree(self.name, ignore_errors=True)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Graph(object):
|
|
69
|
+
"""
|
|
70
|
+
Visualisation of the tree
|
|
71
|
+
|
|
72
|
+
Parameters
|
|
73
|
+
----------
|
|
74
|
+
tree : iterable CHAID tree
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(self, tree):
|
|
78
|
+
self.tree = tree
|
|
79
|
+
|
|
80
|
+
def render(self, path, view):
|
|
81
|
+
if path is None:
|
|
82
|
+
path = os.path.join("trees", "{:%Y-%m-%d %H:%M:%S}.gv".format(datetime.now()))
|
|
83
|
+
with TemporaryDirectory() as self.tempdir:
|
|
84
|
+
g = Digraph(
|
|
85
|
+
format="png",
|
|
86
|
+
graph_attr={"splines": "ortho"},
|
|
87
|
+
node_attr={"shape": "plaintext", "labelloc": "b"},
|
|
88
|
+
)
|
|
89
|
+
for node in self.tree:
|
|
90
|
+
image = self.bar_chart(node)
|
|
91
|
+
g.node(str(node.node_id), image=image)
|
|
92
|
+
if node.parent is not None:
|
|
93
|
+
edge_label = " ({}) \n ".format(', '.join(map(str, node.choices)))
|
|
94
|
+
g.edge(str(node.parent), str(node.node_id), xlabel=edge_label)
|
|
95
|
+
g.render(path, view=view)
|
|
96
|
+
|
|
97
|
+
def bar_chart(self, node):
|
|
98
|
+
fig = dict(
|
|
99
|
+
data=[
|
|
100
|
+
dict(
|
|
101
|
+
values=list(node.members.values()),
|
|
102
|
+
labels=list(node.members),
|
|
103
|
+
showlegend=(node.node_id == 0),
|
|
104
|
+
**FIG_BASE_DATA
|
|
105
|
+
)
|
|
106
|
+
],
|
|
107
|
+
**FIG_BASE
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if not node.is_terminal:
|
|
111
|
+
fig["data"].append(self._table(node))
|
|
112
|
+
|
|
113
|
+
filename = os.path.join(self.tempdir, "node-{}.png".format(node.node_id))
|
|
114
|
+
pio.write_image(fig, file=filename, format="png")
|
|
115
|
+
return filename
|
|
116
|
+
|
|
117
|
+
def _table(self, node):
|
|
118
|
+
p = None if node.p is None else format(node.p, ".5f")
|
|
119
|
+
score = None if node.score is None else format(node.score, ".2f")
|
|
120
|
+
values = [p, score, node.split.column]
|
|
121
|
+
return go.Table(
|
|
122
|
+
cells=dict(values=[TABLE_HEADER, values], **TABLE_CELLS_CONFIG),
|
|
123
|
+
**TABLE_CONFIG
|
|
124
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from enum import Enum, unique
|
|
2
|
+
|
|
3
|
+
@unique
|
|
4
|
+
class InvalidSplitReason(Enum):
|
|
5
|
+
"""
|
|
6
|
+
Private class to store possible invalid reasons
|
|
7
|
+
"""
|
|
8
|
+
ALPHA_MERGE = 'p-value greater than alpha merge'
|
|
9
|
+
MIN_CHILD_NODE_SIZE = 'splitting would create nodes with less than the minimum child ' \
|
|
10
|
+
'node size'
|
|
11
|
+
MAX_DEPTH = 'the max depth has been reached'
|
|
12
|
+
MAX_SPLITS = 'number of splits greater than maximum specified'
|
|
13
|
+
MIN_PARENT_NODE_SIZE = 'the minimum parent node size threshold has been reached'
|
|
14
|
+
PURE_NODE = 'the node only contains single category respondents'
|
|
15
|
+
NODE_NOT_EXHAUSTIVE = 'the node is not exhaustive'
|
|
16
|
+
|
|
17
|
+
def __str__(self):
|
|
18
|
+
return self.value
|