简介:这是一份用 C 语言实现 ID3 决策树分类器的完整工程源码与实践素材,面向正在学习机器学习基础算法、希望把信息熵与信息增益原理落实到可运行代码的学习者。内容围绕节点划分展开,涉及数据集与树节点的结构体设计、动态内存分配、文件读写,以及信息熵与信息增益计算、递归建树、类别判定与模型输出等环节,并配有样本文本,便于直接跑通训练与验证流程。压缩包共 41 个文件,以 6 个 pdb、3 个 cpp、3 个 obj、3 个 exe、2 个 txt 等为主,涵盖 VC6 工程配置、源码、编译调试中间文件、可执行程序与样本数据,整体约 1.64MB。工程组织完整,便于对照源码理解从熵计算到递归划分的完整链路,也方便替换自有数据做二次实验。已有 1262 人学习下载。
1. ID3算法用C写:从一份天气数据表说起
不少人第一次接触 ID3算法,是在教科书上那条H(D) = -Σp·log2(p)的公式里,看完感觉自己懂了,真动手写才发现卡点根本不在公式。这份用 C 语言手写的决策树分类器,输入是一张十几行的离散属性表(天气、温度、湿度、风力、是否出门),输出是一棵可以打印、可以预测新样本的树。整套代码不依赖任何机器学习框架,也没有第三方库,gcc编一遍就能跑,适合两类人:一类是想把算法从纸面落到内存结构上的学生和转行者,另一类是要在资源受限环境里塞一个小分类逻辑、又不想拖进几百兆运行时的人。它不解决大规模数据,也不做连续特征,但把「样本集怎么组织、熵怎么算、树怎么递归长出来、内存怎么收回去」这条链路摊得很干净。下面按我实际读代码的顺序拆:先看骨架,再看熵和信息增益,再看建树和预测,最后说几个我踩过的坑和二次开发怎么改。
2. 工程骨架与数据结构:三个结构体撑起整棵树
这个项目的体量不大,但目录切得比很多课程作业规整:头文件和实现分开,数据单独放,编译规则集中在 Makefile 里。拿到源码先别急着看算法,把这份骨架看明白,后面读build_tree的时候才不会在指针里迷路。整棵树的所有信息,最终都落在SampleSet、Attribute、TreeNode这三个结构体上,其余的 .c 文件都是围绕它们做加工。
2.1 目录划分与一次完整的编译运行
我拿到压缩包后先tree了一下,结构大致是这样:
id3-c/ ├── include/ │ ├── dataset.h # 样本集与属性定义、CSV 加载声明 │ ├── entropy.h # 熵与信息增益 │ ├── tree.h # 树节点、建树、打印、释放 │ └── predict.h # 单样本预测 ├── src/ │ ├── main.c │ ├── dataset.c │ ├── entropy.c │ ├── tree.c │ └── predict.c ├── data/ │ └── weather.csv # 14 行离散样本,最后一列是类别 ├── Makefile └── README.mddataset.c只负责把 CSV 读进内存,entropy.c只算纯函数(不持有状态),tree.c负责递归和内存,predict.c是纯查表。这种「数据 / 度量 / 结构 / 推理」四分法看着朴素,改起来很省事——比如后面想把熵换成基尼指数,只动entropy.c一个文件,别处一行不改。
编译和运行就两条命令:
make # 等价于 gcc -std=c11 -O2 -Iinclude src/*.c -o id3 -lm ./id3 data/weather.csv注意-lm:log2()在math.h里,链接时漏掉这个参数,报错是undefined reference to 'log2',新手最容易在这里卡十分钟。Makefile 里对应的规则我一般写成显式依赖,而不是通配符一把梭:
CC = gcc CFLAGS = -std=c11 -O2 -Wall -Wextra -Iinclude LDLIBS = -lm TARGET = id3 SRCS = $(wildcard src/*.c) OBJS = $(SRCS:.c=.o) $(TARGET): $(OBJS) $(CC) $(OBJS) -o $@ $(LDLIBS) %.o: %.c $(CC) $(CFLAGS) -c $< -o $@ clean: rm -f $(OBJS) $(TARGET)-Wall -Wextra建议保留。手写递归加裸指针,编译器给的unused parameter、comparison between signed and unsigned这类警告,往往就是后面越界或者死循环的源头。-O2在这个规模下影响不大,但能让十万行级别的样本跑得快一些,留着不吃亏。
运行成功的标志是终端先打印解析到的样本数和属性数,再打印一棵缩进树,最后输出训练集上的准确率。如果只看到样本数就退出,基本是 CSV 解析阶段静默返回了,往下看第 5 章的排查。
2.2 SampleSet、Attribute、TreeNode 三个核心结构体
先看数据侧的三个定义,都在dataset.h里:
#define MAX_ATTR 16 /* 最多支持多少个属性列 */ #define MAX_VAL 32 /* 每个属性的取值种类上限 */ #define MAX_NAME 64 /* 属性名 / 取值 / 类别名的字符串长度 */ #define MAX_SAMPLES 4096 /* 单次加载的样本上限 */ typedef struct { char name[MAX_NAME]; /* 属性名,如 outlook */ int n_values; /* 该属性出现过多少种取值 */ char values[MAX_VAL][MAX_NAME]; /* 取值表,如 sunny/overcast/rain */ int is_numeric; /* 预留:连续属性标记,ID3 原生不用 */ } Attribute; typedef struct { char value[MAX_ATTR][MAX_NAME]; /* 一行样本,按属性下标存字符串 */ } Row; typedef struct { int n_samples; /* 当前子集里还剩多少条样本 */ int n_attrs; /* 属性总数(不变) */ Attribute *attrs; /* 指向全局属性表,不拷贝 */ int *attr_ids; /* 当前还能用来分裂的属性下标 */ int n_attr_left; /* 上者长度,每分裂一次减一 */ Row *rows; /* 样本数组 */ char **label; /* 每条样本的类别,指向原始字符串 */ } SampleSet;这里有两个设计选择值得说。第一,样本值统一用char数组存,不做数值化。ID3 处理的是离散属性,"sunny" 和 "rain" 之间没有大小关系,存字符串反而最贴近语义,也省掉了编码映射表。代价是strcmp比int比较慢,几千条样本完全无所谓。第二,SampleSet里存的是attr_ids而不是「已经用过的属性位图」。位图看着省事,但递归里每层都要传,容易在拷贝时漏掉;用「可用属性下标数组」,每分裂一次就把当前属性剔出去,语义直接,也不容易写错。
树节点:
typedef struct TreeNode { int attr_id; /* 分裂所用的属性下标;叶子节点为 -1 */ char value[MAX_NAME]; /* 从父节点连到本节点的那条边上的取值 */ char label[MAX_NAME]; /* 叶子节点的预测类别 */ int is_leaf; /* 1 表示叶子 */ struct TreeNode **children; /* 子节点数组,顺序与父属性 values 一一对应 */ int n_children; } TreeNode;value存在子节点上而不是父节点的边上,是为了让每个节点自带「我是怎么被到达的」这条信息,打印树和调试的时候非常方便,print_tree递归时不用额外传上下文。children用指针数组而非定长数组,是因为属性取值个数要到运行时才知道;代价是分配了两次内存,释放时也得释放两层,第 5 章会专门讲这个坑。
Attribute里的values表在加载阶段动态收集:读一行样本,遇到没见过的取值就往里追加,同时维护n_values。这样属性表是从数据里长出来的,不需要在代码里硬编码天气数据的取值集合,换一份数据照样能用——这是这份源码比多数课程作业更实用的一点,虽然上限MAX_VAL还是得手工调。
3. 信息熵与信息增益:ID3选择分裂属性的全部依据
ID3 的全部「智能」就在一个判断上:当前节点该用哪个属性分裂。它的答案是——选让子集最纯的那个,衡量纯度的指标是信息熵,衡量「纯了多少」的指标是信息增益。entropy.c里就两个函数,一个算熵,一个算增益,加起来不到八十行,但 ID3算法 的所有行为特征都由它俩决定,包括它对多值属性的偏爱和对连续属性的无能为力。
3.1 用线性查找统计类别分布算熵
算熵的第一步是统计当前子集里每个类别出现了多少次。这份代码没上哈希表,直接开两个定长数组做线性查找:
/* 计算样本集的信息熵,类别分布越均匀熵越大 */ double calc_entropy(const SampleSet *s) { if (s->n_samples == 0) return 0.0; /* 空集约定熵为 0,必须挡在前面 */ int count[MAX_SAMPLES] = {0}; /* 每个类别出现的次数 */ char labels[MAX_SAMPLES][MAX_NAME]; /* 去重后的类别名 */ int n_labels = 0; for (int i = 0; i < s->n_samples; i++) { int k = -1; for (int j = 0; j < n_labels; j++) { if (strcmp(labels[j], s->label[i]) == 0) { k = j; break; } } if (k < 0) { /* 新类别,追加 */ k = n_labels++; strncpy(labels[k], s->label[i], MAX_NAME - 1); labels[k][MAX_NAME - 1] = '\0'; } count[k]++; } double H = 0.0; for (int k = 0; k < n_labels; k++) { double p = (double)count[k] / s->n_samples; H -= p * log2(p); /* 2 为底,单位是 bit */ } return H; }逻辑说明:先扫一遍样本把类别去重并计数,再按-Σ p·log2(p)累加。函数是纯函数,不修改入参,递归里被调用几百次也不会有副作用。参数只有一个const SampleSet *,返回double。
几个细节值得注意。n_samples == 0的守卫必须放在最前面,否则p = 0/0会得到nan,nan参与任何比较都返回 false,最优属性选择会静默失效——这个坑我到第 5 章还会再提一次。count和labels开在栈上,MAX_SAMPLES = 4096时两个数组加起来大约 260KB,默认栈空间 8MB 够用,但如果有人把MAX_SAMPLES调到十万,这里就会直接栈溢出,改成malloc更稳妥。用strncpy加手动补\0而不是strcpy,是因为类别名来自外部 CSV,长度不可控。
最后log2的底数决定了熵的单位是 bit。换成log也行,只要增益函数里前后一致,选出来的属性完全一样——增益的比较只依赖相对大小。有些实现用基尼指数替代熵,改的就是这一个函数,其余代码不用动。
3.2 按取值切子集并计算加权条件熵
有了熵,信息增益就是「分裂前的熵减去分裂后的加权平均熵」。核心难点不在减法,而在「按某个属性的每个取值把样本切开」,这个动作在split_by_value里:
/* 切出 attr_id 取值等于 value 的子集,同时把该属性从可用列表中移除 */ int split_by_value(const SampleSet *s, int attr_id, const char *value, SampleSet *out) { out->attrs = s->attrs; /* 属性表全局共享,不深拷贝 */ out->n_attrs = s->n_attrs; out->attr_ids = malloc(sizeof(int) * (s->n_attr_left > 0 ? s->n_attr_left : 1)); out->n_attr_left = 0; for (int i = 0; i < s->n_attr_left; i++) { if (s->attr_ids[i] != attr_id) /* 关键:已用过的属性不再参与分裂 */ out->attr_ids[out->n_attr_left++] = s->attr_ids[i]; } out->rows = malloc(sizeof(Row) * s->n_samples); out->label = malloc(sizeof(char *) * s->n_samples); out->n_samples = 0; for (int i = 0; i < s->n_samples; i++) { if (strcmp(s->rows[i].value[attr_id], value) == 0) { out->rows[out->n_samples] = s->rows[i]; /* Row 是值拷贝,安全 */ out->label[out->n_samples] = s->label[i]; out->n_samples++; } } return out->n_samples; /* 返回 0 表示该取值下没有样本 */ }参数说明:s是父节点样本集,attr_id是要切的属性下标,value是具体取值,out是调用方负责释放的输出子集。返回值是切出来的样本数,调用方必须判断它是否为 0,否则后面算熵之前就出问题。
这里最关键的一行是if (s->attr_ids[i] != attr_id)。它保证了一个属性在一条根到叶的路径上只被用一次。漏掉这一行的后果非常直观:树会一直用同一个属性反复分裂,深度失控,小数据集也能跑出上百层,最后栈溢出。Row用的是值拷贝(out->rows[n] = s->rows[i]),整个Row是char[16][64],一条 1KB,拷贝量不算小,但换来的是子集完全独立,父集释放后子集数据依然有效,递归写起来简单。
有了切分,增益就是一行加权:
/* 信息增益 = 父熵 - Σ(子集占比 × 子集熵) */ double info_gain(const SampleSet *s, int attr_id) { double base = calc_entropy(s); Attribute *a = &s->attrs[attr_id]; double cond = 0.0; for (int v = 0; v < a->n_values; v++) { SampleSet sub; int n = split_by_value(s, attr_id, a->values[v], &sub); if (n == 0) { free_sampleset(&sub); continue; } /* 空分支不计入加权 */ cond += ((double)n / s->n_samples) * calc_entropy(&sub); free_sampleset(&sub); } return base - cond; }cond是条件熵,权重是子集样本数占父集的比例,这个权重不能省——省了就等于把一个小分支和大分支同等看待,选出来的属性会偏向取值种类多的那些。n == 0的跳过也是必须的:某个取值在训练集里一次都没出现,它不该影响增益计算。这也意味着n_values里可能包含「当前子集用不到的取值」,建树时这些分支会指向一个没有样本的子树,最后变成多数投票叶子,属于正常现象。
顺带说一个 ID3 的固有倾向:取值越多的属性,切出来的子集越多、越纯,条件熵越低,增益天然越高。典型例子是「学号」这种属性,每个取值一条样本,增益拉满,但完全没有泛化能力。这是算法本身的缺陷,不是代码写错了,后面第 6 章讲改造时会给出缓解办法。
4. 递归建树与预测:训练和推理的两条链路
tree.c只有三个函数:建树、打印、释放。建树是唯一有递归的地方,也是整份代码里最容易出错的地方。读它的时候建议先看终止条件,再看分裂,最后看递归调用,顺序反了容易陷进细节。
4.1 build_tree 的三个终止条件和一次分裂
递归函数的标准套路是先写出口。这里一共有三个,缺一个都会出问题:
TreeNode *build_tree(const SampleSet *s) { TreeNode *node = calloc(1, sizeof(TreeNode)); /* calloc 保证指针字段清零 */ node->attr_id = -1; /* 出口 1:所有样本同类,直接成叶 */ if (all_same_label(s)) { node->is_leaf = 1; strncpy(node->label, s->label[0], MAX_NAME - 1); return node; } /* 出口 2:没有可用属性,或者样本已空,用多数投票兜底 */ if (s->n_attr_left == 0 || s->n_samples == 0) { node->is_leaf = 1; strncpy(node->label, majority_label(s), MAX_NAME - 1); return node; } /* 选信息增益最大的属性作为分裂依据 */ int best = s->attr_ids[0]; /* 注意初值,不能是 -1 或 0 */ double best_gain = -1.0; for (int i = 0; i < s->n_attr_left; i++) { double g = info_gain(s, s->attr_ids[i]); if (g > best_gain) { best_gain = g; best = s->attr_ids[i]; } } node->attr_id = best; node->n_children = s->attrs[best].n_values; node->children = calloc(node->n_children, sizeof(TreeNode *)); for (int v = 0; v < node->n_children; v++) { SampleSet sub; split_by_value(s, best, s->attrs[best].values[v], &sub); node->children[v] = build_tree(&sub); /* 递归 */ strncpy(node->children[v]->value, s->attrs[best].values[v], MAX_NAME - 1); free_sampleset(&sub); /* 子集用完即释放 */ } return node; }参数只有一个const SampleSet *,返回新建的子树根。calloc而不是malloc很重要:is_leaf、n_children、children都靠初始零值,用malloc就得手动逐个清零,漏一个就是野指针。
三个出口分别对应三种情况。出口 1 是理想情况,子集纯了就不用再分。出口 2 是兜底:属性用完了还没纯(比如两个样本所有属性都一样但类别不同,数据本身矛盾),或者子集为空(父节点的某个分支没有落入任何样本),这时候用多数投票给一个类别,树必须停下来。这两个出口合起来保证了递归一定会终止——每层至少消耗一个属性,n_attr_left严格递减,最多递归n_attrs层。
best的初值写的是s->attr_ids[0]。如果写成 -1,当所有属性的增益都是 0(比如属性全是噪声)时,g > best_gain恒为假,best保持 -1,然后s->attrs[-1]直接越界。这个 bug 在正常数据上跑不出来,换一份含噪声的数据就崩。用第一个属性做初值,同时把best_gain设为 -1.0(增益可能为负),是最省心的写法。
value是在递归返回之后才写到子节点上的,顺序不能反——build_tree内部会calloc出新节点并返回,必须等指针拿到手才能写字段。free_sampleset(&sub)放在循环体内而不是循环外,是因为每次迭代的sub是独立分配的,放到循环外只会释放最后一次,前面的全泄漏。
4.2 predict 沿着属性取值走一条路径
训练完的树是一堆嵌套指针,预测就是按属性取值一路往下走,走到叶子取类别:
/* 返回预测类别;遇到训练集没见过的取值返回 NULL */ const char *predict(const TreeNode *root, const Row *r) { const TreeNode *p = root; while (p && !p->is_leaf) { const char *v = r->value[p->attr_id]; /* 取当前节点属性在这条样本上的值 */ const TreeNode *next = NULL; for (int i = 0; i < p->n_children; i++) { if (strcmp(p->children[i]->value, v) == 0) { next = p->children[i]; break; } } if (!next) return NULL; /* 未知取值,走到树外了 */ p = next; } return p ? p->label : NULL; }用while而不是递归,是因为这是尾递归,展开成循环省栈也更快。子节点查找是线性扫描n_children,属性取值一般就三五种,比建哈希快。返回NULL用在「训练时没见过这个取值」的情况,调用方需要判断,否则printf("%s", NULL)在某些 libc 上直接段错误。
main.c里的用法是把训练集回代算准确率:
int correct = 0; for (int i = 0; i < train.n_samples; i++) { const char *pred = predict(root, &train.rows[i]); if (pred && strcmp(pred, train.label[i]) == 0) correct++; } printf("train accuracy: %.2f%%\n", 100.0 * correct / train.n_samples);这里有个必须说清的认知:回代准确率只能说明「树把训练集记住了」,不能说明模型好坏。ID3 不剪枝时几乎必然在训练集上达到 100%(样本不矛盾的前提下),因为它会把每条样本都分到纯叶子里。想看真实效果,得手动切一部分数据出来做验证,这件事第 6 章会展开。
5. 避坑与常见问题排查:C写树的五个血泪坑
这份代码逻辑不复杂,但用 C 手写递归结构,坑几乎全在「边界」和「内存」上。下面五条是我在调试和改造过程中真实撞到的,按现象、原因、解决三段的顺序写,遇到类似症状可以直接对号入座。
5.1 数据与算法层面的三个坑
第一个坑:树深得离谱,小数据集也能跑上百层,最后段错误。
现象是程序打印出来的树缩进一直往右跑,或者干脆崩溃。原因基本是split_by_value里忘了把当前属性从attr_ids中剔除,或者剔除逻辑写成了「保留等于 attr_id 的」,导致同一属性在一条路径上反复分裂。因为每次分裂后子集变纯,增益变成 0,但选择逻辑仍然会选中它(前面说的best初值问题会掩盖这一点)。解决是回头检查那一行if (s->attr_ids[i] != attr_id),并且在build_tree里打印一下当前深度,超过n_attrs就说明递归没收敛,直接assert(depth <= s->n_attrs)卡死比让它静默跑飞好排查得多。
第二个坑:所有属性的信息增益都是 0,选出来的分裂属性毫无意义。
现象是根节点的属性看起来和类别完全不相关,树又宽又浅,预测准确率接近随机。这通常是连续值被当成离散值喂进来了:如果一个属性是「温度 23、24、25…」这种,每个数值变成一种取值,每个子集只有一条样本,熵为 0,增益被拉到最大值,算法会毫不犹豫地选它。ID3 原生只支持离散属性,解决要么先做分箱(第 6 章给代码),要么换成支持二分的信息增益比思路。另一种可能是算熵时n_samples用了父集的数量而不是子集的,导致权重错误,增益全成负数。
第三个坑:跑出来的准确率是-nan%或者熵打印成nan。
现象是终端里出现nan,程序不崩但结果全错。原因就是空子集没挡住:某个取值下一条样本都没有,n_samples为 0,calc_entropy里p = 0/0得到nan,nan再和各种增益比较,>全部返回 false,最优属性选择就退化成「永远选第一个」。解决很简单,calc_entropy开头加if (s->n_samples == 0) return 0.0;,info_gain里加if (n == 0) continue;。这两行不加,在数据分布均匀的时候完全看不出问题,一旦某个类别样本很少就原地爆炸。我一般还会在calc_entropy里加一句assert(s->n_samples > 0),开发期直接断言,比事后 debug 省事。
5.2 内存与字符串层面的两个坑
第四个坑:跑一次没事,放进循环里训练几十次,内存一路涨。
现象是valgrind --leak-check=full报出一堆definitely lost,全部指向TreeNode和rows。原因是树的释放只做了free(root)一层,children数组和每个子节点都没管。正确的释放必须后序遍历,先递归释放子节点,再释放children数组本身,最后释放当前节点:
void free_tree(TreeNode *node) { if (!node) return; for (int i = 0; i < node->n_children; i++) free_tree(node->children[i]); /* 先递归释放子树 */ free(node->children); /* 再释放指针数组 */ free(node); /* 最后释放自己 */ }顺序反了会 use-after-free,跳过后两步就是纯泄漏。SampleSet那边同理,rows、label、attr_ids是三块独立内存,free_sampleset必须三个都释放;而且attrs是共享的,绝对不能在里面free(s->attrs),否则第二个子集释放时就是 double free。建议把释放函数写在建树函数前面,先想清楚怎么收,再写怎么建。
第五个坑:换个数据集就崩,或者在预测时输出乱码。
现象是加载新 CSV 时strcpy越界,或者属性名打印出来带一串乱码。原因是MAX_ATTR、MAX_NAME这些宏是硬上限,而加载函数里用的是不检查长度的strcpy。CSV 里如果有个长属性名,或者列数超过 16,就会踩到别的字段上。解决是把所有strcpy换成strncpy并手动补\0(这份源码在类别名上做了,属性名和取值上有的地方没做),加载时对列数和字段长度做显式检查,超限就报错退出而不是静默截断。另外 CSV 用strtok切分时,如果行尾有\r\n(Windows 下编辑过),最后一个字段会带着\r,导致类别字符串永远匹配不上,准确率显示 0%。这个坑的典型症状是「树能建出来,但预测全错」,用od -c data/weather.csv | head看一眼行尾就水落石出。
6. 二次开发:把ID3改造成能上生产的小分类器
这份代码当玩具跑没问题,真要接到业务里,我一般按三个方向改,改动量都不大,但收益明显。
第一是连续属性分箱。ID3 吃不了浮点数,最省事的做法是等频分箱,把一列数值压成 k 个离散桶再喂进去:
/* 等频分箱:把连续列压成 k 个离散桶,bins[i] 是第 i 条样本的桶号 */ void discretize_equal_freq(const double *col, int n, int k, int *bins) { double *sorted = malloc(sizeof(double) * n); memcpy(sorted, col, sizeof(double) * n); qsort(sorted, n, sizeof(double), cmp_double); /* cmp_double 按值升序 */ for (int i = 0; i < n; i++) { int lo = 0, hi = n - 1; while (lo < hi) { /* 二分找当前值在有序表里的排名 */ int mid = (lo + hi) / 2; if (sorted[mid] < col[i]) lo = mid + 1; else hi = mid; } int b = (int)((long)lo * k / n); bins[i] = (b >= k) ? k - 1 : b; } free(sorted); }k建议从 5 起,样本少于 200 条时用等宽分箱更稳,因为等频分箱在重复值多的时候会退化成少数几个桶。分完箱记得把桶号写回Row并同步更新Attribute.values,否则split_by_value匹配不上。
第二是剪枝。不剪枝的 ID3 必然过拟合训练集,最简单的预剪枝是在build_tree里加两个门槛:子集样本数小于min_samples_split就直接成叶,或者增益低于min_gain就停止分裂。后剪枝需要一份验证集,用「删掉子树换成叶子后验证集准确率是否不降」来判断,实现麻烦但效果更好。我一般先用预剪枝兜底,几十行代码,性价比最高。
第三是输出可移植化。把print_tree改成输出 JSON 或者一段扁平的if-elseC 代码,模型就能脱离这份源码单独使用,嵌进别的工程里只带一个头文件。下面这张表是我常改的几处和对应的验证方式:
| 改造方向 | 主要改动点 | 怎么验证没改坏 |
|---|---|---|
| 连续属性 | dataset.c加载后加分箱,更新取值表 | 同一份数据分箱前后准确率不应掉超过 5 个点 |
| 预剪枝 | build_tree增加min_samples_split参数 | 训练集准确率下降、验证集准确率上升才对 |
| 后剪枝 | 新增prune(root, valid_set)后序遍历 | 树节点数明显减少且验证集不降 |
| 模型导出 | 重写print_tree为 JSON 或 if-else | 导出的模型在独立程序里预测结果与原树一致 |
| 多分类概率 | 叶子存类别计数而非单一 label | 输出概率之和为 1,argmax 与原预测一致 |
最后说个习惯。我最早写这类递归结构时,总是先写建树、跑通了再想释放,结果每改一版就在valgrind里捞一次泄漏。从那以后我每次动递归树,都先把free_tree和后序遍历写完,再写build_tree,跑通当天必须过一次valgrind --leak-check=full才敢接真实数据——这个顺序换过来之后,调试时间至少少了一半。这份源码的骨架够干净,按上面三条改完,当个轻量分类器用是够的。希望帮到你。
本文还有配套的精品资源,点击获取