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.
Files changed (47) hide show
  1. chaid_segmenter-0.1.0/CHAID/__init__.py +8 -0
  2. chaid_segmenter-0.1.0/CHAID/__main__.py +110 -0
  3. chaid_segmenter-0.1.0/CHAID/column.py +333 -0
  4. chaid_segmenter-0.1.0/CHAID/graph.py +124 -0
  5. chaid_segmenter-0.1.0/CHAID/invalid_split_reason.py +18 -0
  6. chaid_segmenter-0.1.0/CHAID/mapping_dict.py +5 -0
  7. chaid_segmenter-0.1.0/CHAID/node.py +122 -0
  8. chaid_segmenter-0.1.0/CHAID/split.py +90 -0
  9. chaid_segmenter-0.1.0/CHAID/stats.py +264 -0
  10. chaid_segmenter-0.1.0/CHAID/tree.py +316 -0
  11. chaid_segmenter-0.1.0/LICENSE.txt +202 -0
  12. chaid_segmenter-0.1.0/MANIFEST.in +3 -0
  13. chaid_segmenter-0.1.0/NOTICE +17 -0
  14. chaid_segmenter-0.1.0/PKG-INFO +324 -0
  15. chaid_segmenter-0.1.0/README.md +289 -0
  16. chaid_segmenter-0.1.0/chaid_segmenter/__init__.py +37 -0
  17. chaid_segmenter-0.1.0/chaid_segmenter/binning.py +407 -0
  18. chaid_segmenter-0.1.0/chaid_segmenter/layout.py +64 -0
  19. chaid_segmenter-0.1.0/chaid_segmenter/metrics.py +55 -0
  20. chaid_segmenter-0.1.0/chaid_segmenter/plotting.py +188 -0
  21. chaid_segmenter-0.1.0/chaid_segmenter/rules.py +61 -0
  22. chaid_segmenter-0.1.0/chaid_segmenter/segmenter.py +397 -0
  23. chaid_segmenter-0.1.0/chaid_segmenter.egg-info/PKG-INFO +324 -0
  24. chaid_segmenter-0.1.0/chaid_segmenter.egg-info/SOURCES.txt +46 -0
  25. chaid_segmenter-0.1.0/chaid_segmenter.egg-info/dependency_links.txt +1 -0
  26. chaid_segmenter-0.1.0/chaid_segmenter.egg-info/requires.txt +6 -0
  27. chaid_segmenter-0.1.0/chaid_segmenter.egg-info/top_level.txt +2 -0
  28. chaid_segmenter-0.1.0/pyproject.toml +40 -0
  29. chaid_segmenter-0.1.0/setup.cfg +7 -0
  30. chaid_segmenter-0.1.0/setup.py +75 -0
  31. chaid_segmenter-0.1.0/tests/test_continuous_column.py +32 -0
  32. chaid_segmenter-0.1.0/tests/test_graph.py +35 -0
  33. chaid_segmenter-0.1.0/tests/test_graph_optional_import.py +46 -0
  34. chaid_segmenter-0.1.0/tests/test_node.py +25 -0
  35. chaid_segmenter-0.1.0/tests/test_nominal_column.py +179 -0
  36. chaid_segmenter-0.1.0/tests/test_ordinal_column.py +298 -0
  37. chaid_segmenter-0.1.0/tests/test_segmenter_binary.py +90 -0
  38. chaid_segmenter-0.1.0/tests/test_segmenter_binning.py +109 -0
  39. chaid_segmenter-0.1.0/tests/test_segmenter_continuous.py +36 -0
  40. chaid_segmenter-0.1.0/tests/test_segmenter_highcard.py +99 -0
  41. chaid_segmenter-0.1.0/tests/test_segmenter_inference.py +85 -0
  42. chaid_segmenter-0.1.0/tests/test_segmenter_loaders.py +32 -0
  43. chaid_segmenter-0.1.0/tests/test_segmenter_plotting.py +66 -0
  44. chaid_segmenter-0.1.0/tests/test_segmenter_rules.py +39 -0
  45. chaid_segmenter-0.1.0/tests/test_split.py +31 -0
  46. chaid_segmenter-0.1.0/tests/test_stats.py +150 -0
  47. chaid_segmenter-0.1.0/tests/test_tree.py +748 -0
@@ -0,0 +1,8 @@
1
+ from .split import Split
2
+ from .tree import Tree
3
+ from .node import Node
4
+ from .column import NominalColumn, OrdinalColumn, ContinuousColumn
5
+ from .stats import Stats
6
+ from .invalid_split_reason import InvalidSplitReason
7
+
8
+ __version__ = "5.4.3"
@@ -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
@@ -0,0 +1,5 @@
1
+ class MappingDict(dict):
2
+ """ a dict with a default value when no key present """
3
+ def __missing__(self, key):
4
+ value = self[key] = [key]
5
+ return value