001/*
002 * Copyright (c) 2015, 2022, Oracle and/or its affiliates. All rights reserved.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package org.tribuo.classification.dtree.impl;
018
019import com.oracle.labs.mlrg.olcut.util.Pair;
020import org.tribuo.Dataset;
021import org.tribuo.Example;
022import org.tribuo.ImmutableFeatureMap;
023import org.tribuo.ImmutableOutputInfo;
024import org.tribuo.classification.Label;
025import org.tribuo.classification.dtree.impurity.LabelImpurity;
026import org.tribuo.common.tree.AbstractTrainingNode;
027import org.tribuo.common.tree.LeafNode;
028import org.tribuo.common.tree.Node;
029import org.tribuo.common.tree.SplitNode;
030import org.tribuo.common.tree.impl.IntArrayContainer;
031import org.tribuo.math.la.SparseVector;
032import org.tribuo.math.la.VectorTuple;
033import org.tribuo.util.Util;
034
035import java.io.IOException;
036import java.io.NotSerializableException;
037import java.util.ArrayList;
038import java.util.Arrays;
039import java.util.Collections;
040import java.util.LinkedHashMap;
041import java.util.List;
042import java.util.Map;
043import java.util.SplittableRandom;
044import java.util.logging.Logger;
045
046/**
047 * A decision tree node used at training time.
048 * Contains a list of the example indices currently found in this node,
049 * the current impurity and a bunch of other statistics.
050 */
051public class ClassifierTrainingNode extends AbstractTrainingNode<Label> {
052    private static final long serialVersionUID = 1L;
053
054    private static final Logger logger = Logger.getLogger(ClassifierTrainingNode.class.getName());
055
056    private static final ThreadLocal<IntArrayContainer> mergeBufferOne = ThreadLocal.withInitial(() -> new IntArrayContainer(DEFAULT_SIZE));
057    private static final ThreadLocal<IntArrayContainer> mergeBufferTwo = ThreadLocal.withInitial(() -> new IntArrayContainer(DEFAULT_SIZE));
058    private static final ThreadLocal<IntArrayContainer> mergeBufferThree = ThreadLocal.withInitial(() -> new IntArrayContainer(DEFAULT_SIZE));
059
060    private transient ArrayList<TreeFeature> data;
061
062    private final ImmutableOutputInfo<Label> labelIDMap;
063
064    private final ImmutableFeatureMap featureIDMap;
065
066    private final LabelImpurity impurity;
067
068    private final float[] weightedLabelCounts;
069
070    private final float weightSum;
071
072    /**
073     * Constructor which creates the inverted file.
074     * @param impurity The impurity function to use.
075     * @param examples The training data.
076     * @param leafDeterminer Contains parameters needed to determine whether a child node will be a leaf.
077     */
078    public ClassifierTrainingNode(LabelImpurity impurity, Dataset<Label> examples, LeafDeterminer leafDeterminer) {
079        this(impurity,invertData(examples), examples.size(), 0, examples.getFeatureIDMap(),
080                examples.getOutputIDInfo(),leafDeterminer);
081    }
082
083    private ClassifierTrainingNode(LabelImpurity impurity, ArrayList<TreeFeature> data, int numExamples, int depth,
084                                   ImmutableFeatureMap featureIDMap, ImmutableOutputInfo<Label> labelIDMap,
085                                   LeafDeterminer leafDeterminer) {
086        super(depth, numExamples, leafDeterminer);
087        this.data = data;
088        this.featureIDMap = featureIDMap;
089        this.labelIDMap = labelIDMap;
090        this.impurity = impurity;
091        this.weightedLabelCounts = data.get(0).getWeightedLabelCounts();
092        this.weightSum = Util.sum(weightedLabelCounts);
093        this.impurityScore = impurity.impurity(weightedLabelCounts);
094    }
095
096    private ClassifierTrainingNode(LabelImpurity impurity, ArrayList<TreeFeature> data, int numExamples, int depth,
097                                   ImmutableFeatureMap featureIDMap, ImmutableOutputInfo<Label> labelIDMap,
098                                   LeafDeterminer leafDeterminer, float[] weightedLabelCounts, float weightSum,
099                                   double impurityScore) {
100        super(depth, numExamples, leafDeterminer);
101        this.data = data;
102        this.featureIDMap = featureIDMap;
103        this.labelIDMap = labelIDMap;
104        this.impurity = impurity;
105        this.weightedLabelCounts = weightedLabelCounts;
106        this.weightSum = weightSum;
107        this.impurityScore = impurityScore;
108    }
109
110    @Override
111    public float getWeightSum() {
112        return weightSum;
113    }
114
115    @Override
116    public double getImpurity() {
117        return impurityScore;
118    }
119
120    /**
121     * Builds a tree according to CART (as it does not do multi-way splits on categorical values like C4.5).
122     * @param featureIDs Indices of the features available in this split.
123     * @param rng Splittable random number generator.
124     * @param useRandomSplitPoints Whether to choose split points for features at random.
125     * @return A possibly empty list of TrainingNodes.
126     */
127    @Override
128    public List<AbstractTrainingNode<Label>> buildTree(int[] featureIDs, SplittableRandom rng,
129                                                       boolean useRandomSplitPoints) {
130        if (useRandomSplitPoints) {
131            return buildRandomTree(featureIDs, rng);
132        } else {
133            return buildGreedyTree(featureIDs);
134        }
135    }
136
137    /**
138     * Builds a tree according to CART
139     * @param featureIDs Indices of the features available in this split.
140     * @return A possibly empty list of TrainingNodes.
141     */
142    private List<AbstractTrainingNode<Label>> buildGreedyTree(int[] featureIDs) {
143        int bestID = -1;
144        double bestSplitValue = 0.0;
145        double bestScore = getImpurity();
146        float[] lessThanCountsOfBest = new float[weightedLabelCounts.length];
147        float[] greaterThanCountsOfBest = new float[weightedLabelCounts.length];
148        float[] lessThanCounts = new float[weightedLabelCounts.length];
149        float[] greaterThanCounts = new float[weightedLabelCounts.length];
150        for (int i = 0; i < featureIDs.length; i++) {
151            List<InvertedFeature> feature = data.get(featureIDs[i]).getFeature();
152            Arrays.fill(lessThanCounts,0.0f);
153            System.arraycopy(weightedLabelCounts, 0, greaterThanCounts, 0, weightedLabelCounts.length);
154            // searching for the intervals between features.
155            for (int j = 0; j < feature.size()-1; j++) {
156                InvertedFeature f = feature.get(j);
157                float[] featureCounts = f.getWeightedLabelCounts();
158                Util.inPlaceAdd(lessThanCounts,featureCounts);
159                Util.inPlaceSubtract(greaterThanCounts,featureCounts);
160                // impurityWeighted rescales the impurity by the sum, in order to average properly when you add the
161                // left side & right side together, and then divide by the sum of the counts
162                double lessThanScore = impurity.impurityWeighted(lessThanCounts);
163                double greaterThanScore = impurity.impurityWeighted(greaterThanCounts);
164                double score = (lessThanScore + greaterThanScore) / weightSum;
165                if (score < bestScore) {
166                    bestID = i;
167                    bestScore = score;
168                    System.arraycopy(lessThanCounts,0,lessThanCountsOfBest,0,lessThanCounts.length);
169                    System.arraycopy(greaterThanCounts,0,greaterThanCountsOfBest,0,greaterThanCounts.length);
170                    bestSplitValue = (f.value + feature.get(j + 1).value) / 2.0;
171                }
172            }
173        }
174
175        List<AbstractTrainingNode<Label>> output;
176        double impurityDecrease = weightSum * (getImpurity() - bestScore);
177        // If we found a split better than the current impurity.
178        if ((bestID != -1) && (impurityDecrease >= leafDeterminer.getScaledMinImpurityDecrease())) {
179            output = splitAtBest(featureIDs, bestID, bestSplitValue, lessThanCountsOfBest, greaterThanCountsOfBest);
180        } else {
181            output = Collections.emptyList();
182        }
183        data = null;
184        return output;
185    }
186
187    /**
188     * Builds a CART tree with randomly chosen split points.
189     * @param featureIDs Indices of the features available in this split.
190     * @param rng Splittable random number generator.
191     * @return A possibly empty list of TrainingNodes.
192     */
193    public List<AbstractTrainingNode<Label>> buildRandomTree(int[] featureIDs, SplittableRandom rng) {
194        int bestID = -1;
195        double bestSplitValue = 0.0;
196        double bestScore = getImpurity();
197        float[] lessThanCountsOfBest = new float[weightedLabelCounts.length];
198        float[] greaterThanCountsOfBest = new float[weightedLabelCounts.length];
199        float[] lessThanCounts = new float[weightedLabelCounts.length];
200        float[] greaterThanCounts = new float[weightedLabelCounts.length];
201
202        // split each feature once randomly and record the least impure amongst these
203        for (int i = 0; i < featureIDs.length; i++) {
204            List<InvertedFeature> feature = data.get(featureIDs[i]).getFeature();
205
206            // if there is only 1 inverted feature for this feature, it has only 1 value, so cannot be split
207            if (feature.size() == 1) {
208                continue;
209            }
210
211            Arrays.fill(lessThanCounts,0.0f);
212            System.arraycopy(weightedLabelCounts, 0, greaterThanCounts, 0, weightedLabelCounts.length);
213
214            int splitIdx = rng.nextInt(feature.size()-1);
215            for (int j = 0; j < splitIdx + 1; j++) {
216                InvertedFeature vf = feature.get(j);
217                float[] countsBelowOrEqual = vf.getWeightedLabelCounts();
218                Util.inPlaceAdd(lessThanCounts, countsBelowOrEqual);
219                Util.inPlaceSubtract(greaterThanCounts, countsBelowOrEqual);
220            }
221            double lessThanScore = impurity.impurityWeighted(lessThanCounts);
222            double greaterThanScore = impurity.impurityWeighted(greaterThanCounts);
223            double score = (lessThanScore + greaterThanScore) / weightSum;
224            if (score < bestScore) {
225                bestID = i;
226                bestScore = score;
227                System.arraycopy(lessThanCounts,0,lessThanCountsOfBest,0,lessThanCounts.length);
228                System.arraycopy(greaterThanCounts,0,greaterThanCountsOfBest,0,greaterThanCounts.length);
229                bestSplitValue = (feature.get(splitIdx).value + feature.get(splitIdx + 1).value) / 2.0;
230            }
231        }
232
233        List<AbstractTrainingNode<Label>> output;
234        double impurityDecrease = weightSum * (getImpurity() - bestScore);
235        // If we found a split better than the current impurity.
236        if ((bestID != -1) && (impurityDecrease >= leafDeterminer.getScaledMinImpurityDecrease())) {
237            output = splitAtBest(featureIDs, bestID, bestSplitValue, lessThanCountsOfBest, greaterThanCountsOfBest);
238        } else {
239            output = Collections.emptyList();
240        }
241        data = null;
242        return output;
243    }
244
245    /**
246     * Splits the data to form two nodes.
247     * @param featureIDs Indices of the features available in this split.
248     * @param bestID ID of the feature on which the split should be based.
249     * @param bestSplitValue Feature value to use for splitting the data.
250     * @param lessThanCounts Weighted label counts for data less than or equal to the split value for the given feature.
251     * @param greaterThanCounts Weighted label counts for data greater than the split value for the given feature.
252     * @return A list of training nodes resulting from the split.
253     */
254    private List<AbstractTrainingNode<Label>> splitAtBest(int[] featureIDs, int bestID, double bestSplitValue,
255                                                          float[] lessThanCounts, float[] greaterThanCounts) {
256        splitID = featureIDs[bestID];
257        split = true;
258        splitValue = bestSplitValue;
259
260        float lessThanWeightSum = Util.sum(lessThanCounts);
261        double lessThanImpurityScore = impurity.impurity(lessThanCounts);
262
263        float greaterThanWeightSum = Util.sum(greaterThanCounts);
264        double greaterThanImpurityScore = impurity.impurity(greaterThanCounts);
265
266        boolean shouldMakeLessThanLeaf = shouldMakeLeaf(lessThanImpurityScore, lessThanWeightSum);
267        boolean shouldMakeGreaterThanLeaf = shouldMakeLeaf(greaterThanImpurityScore, greaterThanWeightSum);
268
269        if (shouldMakeLessThanLeaf && shouldMakeGreaterThanLeaf) {
270            lessThanOrEqual = createLeaf(lessThanImpurityScore, lessThanCounts);
271            greaterThan = createLeaf(greaterThanImpurityScore, greaterThanCounts);
272            return Collections.emptyList();
273        }
274
275        IntArrayContainer lessThanIndices = mergeBufferOne.get();
276        lessThanIndices.size = 0;
277        IntArrayContainer buffer = mergeBufferTwo.get();
278        buffer.size = 0;
279        for (InvertedFeature f : data.get(splitID)) {
280            if (f.value < splitValue) {
281                int[] indices = f.indices();
282                IntArrayContainer.merge(lessThanIndices,indices,buffer);
283                // Swap the buffers
284                IntArrayContainer tmp = lessThanIndices;
285                lessThanIndices = buffer;
286                buffer = tmp;
287            } else {
288                break;
289            }
290        }
291        //logger.info("Splitting on feature " + maxID + " with value " + maxSplitValue + " at depth " + depth + ", " + numExamples + " examples in node.");
292        //logger.info("left indices length = " + lessThanIndices.size);
293        IntArrayContainer secondBuffer = mergeBufferThree.get();
294        secondBuffer.grow(lessThanIndices.size);
295        ArrayList<TreeFeature> lessThanData = new ArrayList<>(data.size());
296        ArrayList<TreeFeature> greaterThanData = new ArrayList<>(data.size());
297        for (TreeFeature feature : data) {
298            Pair<TreeFeature,TreeFeature> split = feature.split(lessThanIndices,buffer,secondBuffer);
299            lessThanData.add(split.getA());
300            greaterThanData.add(split.getB());
301        }
302
303        List<AbstractTrainingNode<Label>> output = new ArrayList<>(2);
304        if (shouldMakeLessThanLeaf) {
305            lessThanOrEqual = createLeaf(lessThanImpurityScore, lessThanCounts);
306        } else {
307            AbstractTrainingNode<Label> tmpNode = new ClassifierTrainingNode(impurity, lessThanData, lessThanIndices.size, depth + 1,
308                    featureIDMap, labelIDMap, leafDeterminer, lessThanCounts, lessThanWeightSum, lessThanImpurityScore);
309            lessThanOrEqual = tmpNode;
310            output.add(tmpNode);
311        }
312
313        if (shouldMakeGreaterThanLeaf) {
314            greaterThan = createLeaf(greaterThanImpurityScore, greaterThanCounts);
315        } else {
316            AbstractTrainingNode<Label> tmpNode = new ClassifierTrainingNode(impurity, greaterThanData, numExamples - lessThanIndices.size,
317                    depth + 1, featureIDMap, labelIDMap, leafDeterminer, greaterThanCounts, greaterThanWeightSum, greaterThanImpurityScore);
318            greaterThan = tmpNode;
319            output.add(tmpNode);
320        }
321        return output;
322    }
323
324    /**
325     * Makes a {@link LeafNode}
326     * @param impurityScore the impurity score for the node.
327     * @param weightedCounts the weighted label counts of the data in the node.
328     * @return a {@link LeafNode}
329     */
330    private LeafNode<Label> createLeaf(double impurityScore, float[] weightedCounts) {
331        double[] normedCounts = Util.normalizeToDistribution(weightedCounts);
332        double maxScore = Double.NEGATIVE_INFINITY;
333        Label maxLabel = null;
334        Map<String,Label> counts = new LinkedHashMap<>();
335        for (int i = 0; i < weightedCounts.length; i++) {
336            final double curCount = normedCounts[i];
337            String name = labelIDMap.getOutput(i).getLabel();
338            Label label = new Label(name,curCount);
339            counts.put(name, label);
340            if (curCount > maxScore) {
341                maxScore = curCount;
342                maxLabel = label;
343            }
344        }
345        return new LeafNode<>(impurityScore,maxLabel,counts,true);
346    }
347
348    /**
349     * Generates a test time tree (made of {@link SplitNode} and {@link LeafNode}) from the tree rooted at this node.
350     * @return A subtree using the SplitNode and LeafNode classes.
351     */
352    @Override
353    public Node<Label> convertTree() {
354        if (split) {
355            return createSplitNode();
356        } else {
357            return createLeaf(getImpurity(), weightedLabelCounts);
358        }
359    }
360
361    /**
362     * Inverts a training dataset from row major to column major. This partially de-sparsifies the dataset
363     * so it's very expensive in terms of memory.
364     * @param examples An input dataset.
365     * @return A list of TreeFeatures which contain {@link InvertedFeature}s.
366     */
367    private static ArrayList<TreeFeature> invertData(Dataset<Label> examples) {
368        ImmutableFeatureMap featureInfos = examples.getFeatureIDMap();
369        ImmutableOutputInfo<Label> labelInfo = examples.getOutputIDInfo();
370        int numLabels = labelInfo.size();
371        int numFeatures = featureInfos.size();
372        int numExamples = examples.size();
373
374        int[] labels = new int[numExamples];
375        float[] weights = new float[numExamples];
376
377        int k = 0;
378        for (Example<Label> e : examples) {
379            weights[k] = e.getWeight();
380            labels[k] = labelInfo.getID(e.getOutput());
381            k++;
382        }
383
384        logger.fine("Building initial List<TreeFeature> for " + numFeatures + " features and " + numLabels + " classes");
385        ArrayList<TreeFeature> data = new ArrayList<>(featureInfos.size());
386
387        for (int i = 0; i < featureInfos.size(); i++) {
388            data.add(new TreeFeature(i,numLabels,labels,weights));
389        }
390
391        for (int i = 0; i < examples.size(); i++) {
392            Example<Label> e = examples.getExample(i);
393            SparseVector vec = SparseVector.createSparseVector(e,featureInfos,false);
394            int lastID = 0;
395            for (VectorTuple f : vec) {
396                int curID = f.index;
397                for (int j = lastID; j < curID; j++) {
398                    data.get(j).observeValue(0.0,i);
399                }
400                data.get(curID).observeValue(f.value,i);
401                //
402                // These two checks should never occur as SparseVector deals with collisions, and Dataset prevents
403                // repeated features.
404                // They are left in just to make sure.
405                if (lastID > curID) {
406                    logger.severe("Example = " + e);
407                    throw new IllegalStateException("Features aren't ordered. At id " + i + ", lastID = " + lastID + ", curID = " + curID);
408                } else if (lastID-1 == curID) {
409                    logger.severe("Example = " + e);
410                    throw new IllegalStateException("Features are repeated. At id " + i + ", lastID = " + lastID + ", curID = " + curID);
411                }
412                lastID = curID + 1;
413            }
414            for (int j = lastID; j < numFeatures; j++) {
415                data.get(j).observeValue(0.0,i);
416            }
417            if (i % 1000 == 0) {
418                logger.fine("Processed example " + i);
419            }
420        }
421
422        logger.fine("Sorting features");
423
424        data.forEach(TreeFeature::sort);
425
426        logger.fine("Fixing InvertedFeature sizes");
427
428        data.forEach(TreeFeature::fixSize);
429
430        logger.fine("Built initial List<TreeFeature>");
431
432        return data;
433    }
434
435    private void writeObject(java.io.ObjectOutputStream stream)
436            throws IOException {
437        throw new NotSerializableException("ClassifierTrainingNode is a runtime class only, and should not be serialized.");
438    }
439}