news 2026/10/7 1:28:29

中文文本分类实战:TensorFlow实现CNN与RNN全流程解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
中文文本分类实战:TensorFlow实现CNN与RNN全流程解析

简介:一套基于TensorFlow的中文文本分类完整实现,同时采用字符级卷积神经网络(CNN)与循环神经网络(RNN)两种模型,适合自然语言处理学习者和需要快速建立文本分类基线的开发者。源码包含数据读取、词汇表构建、类别映射、模型定义、训练与预测等完整流程,并附带划分好的中文新闻数据集:训练集5万条、验证集5千条、测试集1万条;字符级输入无需额外分词,简化了预处理步骤。配套脚本可从原始数据生成子集并合并为统一文本文件,免去自行爬取与清洗数据的麻烦。压缩包共18个文件,以9个Python脚本为核心,另有说明文档、Shell脚本以及展示准确率曲线和网络结构的图片,整体大小仅410KB,目录结构清晰,便于按模块阅读与二次开发。已有170人学习下载,适合课程设计、算法对比实验或TensorFlow文本分类入门时参考,可帮助理解CNN与RNN在中文语料上的建模差异。

1. 中文文本分类的工程化起点:这套 CNN+RNN 源码能帮你省掉哪些重复劳动

做中文文本分类的人,大概率都经历过这种尴尬:理论看了一堆,CNN 卷积怎么扫、RNN 时序怎么传都明白,一上手写代码就卡在数据处理上——分词还是分字、标签怎么编码、vocab 要不要每次重新建、训练集验证集测试集怎么切。这套基于 TensorFlow 的 text-classification-cnn-rnn 项目,正好把这些脏活累活全打包了。它用的是字符级输入,不依赖 jieba 分词,直接在单个汉字上做卷积和循环,对中文场景特别友好,尤其适合刚把 CNN 和 RNN 原理看完、想找个能跑通的全流程代码来对照的初学者。数据集是 cnews 的子集,训练 5 万条、验证 5 千条、测试 1 万条,规模适中,普通显卡甚至 CPU 都能在几小时内跑完一个模型。它不是那种只放几个孤零零 .py 文件的玩具项目,而是连数据生成脚本、预处理模块、模型定义、训练入口、预测脚本全给你配齐了,照着 README 一步步来,能真正把一条文本从原始字符串走到预测类别。

2. 数据从哪来:不靠现成 CSV,自己动手生成训练子集

2.1 原始数据是什么形态,为什么要二次处理

这个项目用的是 THUCNews 的一个子集,原始数据是每个分类一个目录,目录下成千上万个单独的文件,每个文件就是一篇新闻文本。直接拿这种目录结构去训练是不行的,因为 TensorFlow 的数据管道设计上更希望你给它一个统一的、按行存储的文本文件,每一行是一条样本。所以项目的 helper 目录下放了两个脚本,专门干这件事:一个是 shell 脚本 copy_data.sh,用来从每个分类里随机抽固定数量的文件;另一个是 Python 脚本 cnews_group.py,把抽出来的散装文件合并成三个大文件。用这种两步走的方案而不是直接提供一个打包好的数据集,其实有个好处——你以后想换自己的数据源,只要按同样的目录结构放好,就能复用整套流程,不用改模型代码。

2.2 copy_data.sh 的抽取逻辑与参数调整

先看 copy_data.sh,它的作用是从每个分类目录里拷贝指定数量的文件到新目录。常见做法是这个脚本接受一个数字作为参数,表示每个分类抽多少条。

#!/bin/bash # 用法: bash copy_data.sh 6500 # 从每个分类随机拷贝 6500 个文件到新目录,用于生成子集 N=${1:-6500} # 第一个参数是每个分类抽取的文件数,默认 6500 mkdir -p data/cnews for dir in data/thucnews/*/; do class=$(basename "$dir") mkdir -p "data/cnews/$class" # 用 shuf 随机打乱文件列表,取前 N 个 ls "$dir" | shuf -n "$N" | xargs -I {} cp "$dir/{}" "data/cnews/$class/" done

这段脚本的逻辑很直白:遍历原始数据目录下的每个分类文件夹,用 shuf -n N 随机挑 N 个文件名,然后 cp 到新的聚合目录。关键参数就是 N 那个数字,你不需要 6500 条那么多,改成 1000 也能跑,只是训练效果会下降。注意这里的 shuf 是 Linux 命令,macOS 上默认没有,需要先 brew install coreutils,然后用 gshuf 替代。我一般会先抽取少量数据比如每类 200 条做个 smoke test,确认全流程能跑通再放大到 6500。

2.3 cnews_group.py 文件合并与编码处理

抽完文件之后,每个分类还是一个文件夹,下一步得合并成单文件。cnews_group.py 干的就是这个活。

# -*- coding: utf-8 -*- # 将每个分类目录下的所有文件内容合并到一个文本文件 import os import random def group_data(src_dir, dest_file): """ src_dir: 分类目录的根路径,每个子目录是一个分类 dest_file: 输出文件路径,每一行格式为: 文本内容\t类别 """ with open(dest_file, 'w', encoding='utf-8') as out: # 遍历每个分类子目录 for class_name in os.listdir(src_dir): class_path = os.path.join(src_dir, class_name) if not os.path.isdir(class_path): continue for filename in os.listdir(class_path): file_path = os.path.join(class_path, filename) with open(file_path, 'r', encoding='utf-8') as f: content = f.read().strip() if content: # 跳过空文件 out.write(content + '\t' + class_name + '\n')

这个脚本的关键设计在于输出格式:每行是 内容\t标签 的结构,后续的 cnews_loader.py 读取时就按这个分隔符拆。如果你要换成自己的数据集,只需要保证自己的合并脚本输出同样格式就行。编码一定要统一用 UTF-8,否则后面读文件会出现乱码或者报 UnicodeDecodeError。执行完这个脚本,你会得到 cnews.train.txt、cnews.val.txt、cnews.test.txt 三个文件,对应的数据量比例大约是 50:5:10。

3. 数据预处理管线:字符级词汇表构建与 loader 设计

3.1 为什么要用字符级而不是词级

这个项目的预处理核心在 data/cnews_loader.py,它选的是字符级表示——也就是 vocab 里存的是单个汉字,不是词语。选这个方案有三个实际理由:第一,中文没有天然空格分词,做词级要先引入 jieba 之类的分词器,增加依赖和预处理耗时;第二,字符级输入可以直接把文本当作一维序列喂给 CNN 和 RNN,模型结构更简洁;第三,字符级对未登录词的鲁棒性更好,新闻标题里出现新词或者专有名词,分词器可能拆错,但单个字不会。当然代价是序列长度变长,所以后面模型会设置 max_length 做截断。对新手来说,字符级是最容易跑通的选择。

3.2 read_file 与词汇表的增量构建

先看 read_file 和 build_vocab 这一组函数。

# data/cnews_loader.py 核心函数 def read_file(filename): """读取文件数据,返回 contents 列表和 labels 列表""" contents, labels = [], [] with open(filename, 'r', encoding='utf-8') as f: for line in f: line = line.strip() if not line: continue # 按制表符切分,前半是文本,后半是分类 content, label = line.split('\t') contents.append(content) labels.append(label) return contents, labels def build_vocab(train_dir, vocab_dir): """构建字符级词汇表,并保存到 vocab_dir""" contents, _ = read_file(train_dir) # 统计所有出现过的字符 vocab = set() for content in contents: for char in content: vocab.add(char) # 写入词汇表文件,每个字符一行 with open(vocab_dir, 'w', encoding='utf-8') as f: for char in sorted(vocab): f.write(char + '\n')

这里有个容易被忽略的工程细节:build_vocab 接收 train_dir 作为参数,说明词汇表只从训练集构建,验证集和测试集里出现但训练集没有的字会被忽略。这是合理的,因为测试时你不能让模型看到不该看到的信息。vocab 构建完成后会存成文件,下次训练时直接 read_vocab 读回来,不用重新跑一遍全量数据统计。如果你的数据集更新了,记得删掉旧的词汇表文件重建,否则新增的字会被过滤掉,导致准确率莫名其妙下降。

3.3 文本转 ID 序列与 padding 策略

build_vocab 之后是 read_vocab 和 process_file,这两个函数负责把原始文本变成模型能吃的张量。

def read_vocab(vocab_dir): """读取词汇表,返回 {词: id} 映射""" words = [] with open(vocab_dir, 'r', encoding='utf-8') as f: for line in f: line = line.strip() if line: words.append(line) word_to_id = {word: i for i, word in enumerate(words)} return word_to_id, len(words) def process_file(filename, word_to_id, cat_to_id, max_length=600): """将文件内容转换为 id 序列和标签 id,并做 padding""" contents, labels = read_file(filename) # 每个字符转成 id,超出 max_length 的部分截断 data_id = [[word_to_id.get(char, 0) for char in content[:max_length]] for content in contents] # 短于 max_length 的补 0 for i in range(len(data_id)): if len(data_id[i]) < max_length: data_id[i] += [0] * (max_length - len(data_id[i])) label_id = [cat_to_id[label] for label in labels] return data_id, label_id

这里的 max_length=600 是个关键参数。THUCNews 的文本长度参差不齐,有些新闻标题只有十几个字,有些正文能到几千字。600 这个值是根据 cnews 数据集的长度分布调出来的——大部分样本在 600 字以内,超过就截断。如果你处理的是短文本比如微博评论,建议把 max_length 降到 100~200,这样训练速度会快很多,而且不会因为大量 padding 引入噪声。还有一个细节:word_to_id.get(char, 0) 返回 0 表示未知字符,而 padding 也用的是 0,所以 0 这个 id 同时承担两个角色,模型会把"没见过的字"和"空位"一视同仁,这在字符级模型里是可接受的简化。

3.4 分类映射与数据 batch 化

最后是 read_category 和 batch_iter,这两个函数在训练时会配合使用。

def read_category(): """固定分类顺序,返回 {类别: id} 和 id: 类别名 列表""" categories = ['体育', '财经', '房产', '家居', '教育', '科技', '社会', '时尚', '游戏', '娱乐'] cat_to_id = {cat: i for i, cat in enumerate(categories)} return categories, cat_to_id

read_category 写死了 10 个分类,这和你用 cnews_group.py 合并时的原始目录顺序无关,完全由这个列表决定。好处是类别顺序稳定,不会因为文件系统遍历顺序不同导致训练和预测时标签对不上。batch_iter 则是把 data_id 和 label_id 打包成 batch,按 batch_size 逐批 yield 给训练循环。这个项目的 batch_size 默认是 64,如果你的显存有限,改成 32 或者 16 都可以,只是梯度更新频率变了,学习率可能需要相应调小。

4. RNN 模型实现与训练:从 embedding 到双向 LSTM

4.1 模型结构拆解:embedding 层 + 双向 LSTM + 全连接

rnn_model.py 里定义的是一个经典的文本分类 RNN 结构。第一层是 embedding,把每个字符的 id 映射成低维稠密向量,这个向量是可训练的,训练过程中会学到每个字的语义表示。第二层是双向 LSTM,forward 和 backward 两个方向的隐藏状态拼接在一起,相当于模型同时看了"从前往后"和"从后往前"的上下文。为什么要双向?因为中文里一个词的含义往往依赖前后文,比如"方便面"和"很方便",单方向 RNN 只能记住左边的内容,双向能利用右边信息修正理解。最后一层是 dropout 加全连接,输出 10 个类别的 logits。中间隐藏单元数默认是 128,embedding 维度是 64,这两个都是可调的——加大隐藏单元能提升表达力但会明显拖慢训练速度。

4.2 run_rnn.py 训练入口与超参数设置

run_rnn.py 是整个 RNN 训练的入口脚本,核心逻辑是读取预处理后的数据、构建模型、循环训练并保存 checkpoint。下面是精简后的关键训练循环。

# run_rnn.py 核心训练流程 import tensorflow as tf from data.cnews_loader import read_category, read_vocab, process_file, batch_iter # 超参数配置 vocab_size = 5000 # 词汇表大小 embedding_dim = 64 # 词向量维度 hidden_dim = 128 # LSTM 隐藏单元数 num_classes = 10 # 分类数 batch_size = 64 # 每批样本数 learning_rate = 1e-3 # 学习率 num_epochs = 10 # 训练轮数 max_length = 600 # 序列最大长度 # 训练占位符 input_x = tf.placeholder(tf.int32, [None, max_length], name='input_x') input_y = tf.placeholder(tf.int32, [None], name='input_y') keep_prob = tf.placeholder(tf.float32, name='keep_prob') # embedding 层 embedding = tf.Variable(tf.random_uniform([vocab_size, embedding_dim], -1.0, 1.0)) embedding_inputs = tf.nn.embedding_lookup(embedding, input_x) # 双向 LSTM lstm_fw = tf.nn.rnn_cell.LSTMCell(hidden_dim) lstm_bw = tf.nn.rnn_cell.LSTMCell(hidden_dim) outputs, _ = tf.nn.bidirectional_dynamic_rnn(lstm_fw, lstm_bw, embedding_inputs, dtype=tf.float32) # 取最后时间步的输出 output = tf.concat(outputs, 2) output = output[:, -1, :] # dropout + 全连接 output = tf.nn.dropout(output, keep_prob) fc = tf.layers.dense(output, num_classes, name='fc')

这段代码里最容易踩坑的地方是 tf.nn.bidirectional_dynamic_rnn 的输出处理。很多人以为双向 RNN 的输出是对每个时间步的 forward 和 backward 拼接,然后取最后一个时间步的向量,但实际上 bidirectional_dynamic_rnn 返回的 outputs 是一个二元组,需要先用 tf.concat(outputs, 2) 把两个方向的输出拼接起来,再取 [:, -1, :] 作为整个序列的表示。如果直接 output[:, -1, :] 而不拼接,你的输入维度就少了一半,全连接层维度对不上,会报 shape 不匹配的错误。另外注意 keep_prob 这个 placeholder,训练时一般设 0.5,预测时设 1.0,表示不用 dropout。

4.3 训练过程中的观察点与收敛信号

跑 run_rnn.py 的时候,控制台会每隔一定步数打印 loss 和 accuracy。你需要关注两个东西:第一个是 loss 是否在稳步下降,如果前几百步 loss 不降反升,很可能是学习率设大了,把 1e-3 调到 5e-4 再试;第二个是 accuracy 是否快速冲到 90% 以上再缓慢增长,RNN 模型在 cnews 这个数据集上收敛得很快,前 3 轮就能到 90% 左右,后面是缓慢爬升。这个项目会在每个 epoch 结束后保存一次 checkpoints,路径在 run_rnn.py 里的 save_dir 变量,默认是 rnn_checkpoints。如果你中途中断了训练,可以从最近一个 checkpoint 恢复,不用从头跑。

5. CNN 模型对比实现:字符级卷积的配置与性能差异

5.1 cnn_model.py 的网络结构解析

cnn_model.py 实现的是字符级 TextCNN,结构比 RNN 简单很多,但训练速度更快。它的核心是三层结构:embedding 层把字符映射成向量,然后接三个不同尺寸的一维卷积核——通常设置是 2、3、4 或者 3、4、5,分别捕捉二元词组、三元词组和四元词组的局部特征;每个卷积核的输出经过 max pooling 压成一个向量,最后把三个向量拼接起来过全连接层。卷积核的尺寸对应 n-gram 的窗口大小,2 表示看相邻两个字,3 表示看相邻三个字。为什么同时用多组卷积核?因为不同长度的 n-gram 捕获不同粒度的语义特征,短核抓局部搭配,长核抓稍长一点的短语模式。

5.2 run_cnn.py 的卷积超参数配置

run_cnn.py 里的关键超参数和 run_rnn.py 大部分一致,区别在于卷积相关的配置。

# run_cnn.py 中的模型配置段 embedding_dim = 64 # 词向量维度 filter_sizes = [2, 3, 4] # 卷积核尺寸,对应 n-gram 窗口 num_filters = 128 # 每种尺寸的卷积核数量 dropout_keep_prob = 0.5 # 训练时的 dropout 保留率 learning_rate = 1e-3 batch_size = 64 num_epochs = 10

num_filters=128 表示每个卷积尺寸用 128 个卷积核,三个尺寸一共 384 个特征图。这个参数直接影响模型容量,调到 256 会让模型更强但训练时间和显存占用翻倍。filter_sizes 的选择和你的任务有关:短文本比如标题分类,用 [2, 3, 4] 就够了,因为标题本身短,长 n-gram 没有意义;长文本比如新闻正文,可以考虑加一个 5 或者 6。代码里卷积层的实现用 tf.layers.conv1d,输入形状是 [batch_size, max_length, embedding_dim],输出会经过 tf.reduce_max 做全局 max pooling——注意这里是全局池化,对整条序列取最大值,所以最后每个卷积核只产出一个值,池化完的形状是 [batch_size, num_filters * len(filter_sizes)]。

5.3 CNN 和 RNN 在中文数据集上的实际表现差异

在 cnews 这个数据集上跑完两个模型,最直观的差异是训练速度:CNN 每个 epoch 耗时大约是 RNN 的三分之一到二分之一,因为卷积可以高度并行,而 LSTM 的时间步必须串行计算。准确率方面,CNN 通常能到 95% 左右,RNN 在相同配置下大概 94%~95%,差距不明显,但 RNN 的收敛曲线更平滑,CNN 前期波动更大。实际项目里怎么选?如果你的任务对推理速度敏感,比如在线分类接口,选 CNN,因为预测时不需要逐时间步计算;如果你想做序列建模更强的任务,比如情感分析中需要捕捉长距离转折关系,RNN 更合适。还有一个常见做法是把两个模型都跑一遍,用集成或者投票的方式组合结果,能再提零点几个百分点。

6. 避坑与常见问题:训练中遇到的不稳定、维度错误和显存不足

6.1 loss 变成 NaN 或剧烈震荡

现象:训练到一半,控制台输出的 loss 突然变成 nan,或者在某一步剧烈跳变,从 0.5 跳到 120。原因大概率是学习率太大导致梯度爆炸,尤其是 RNN 模型,LSTM 的梯度路径长,更容易炸。解决:先把 learning_rate 降到 1e-4 试试,如果还是 nan,检查 embedding 初始化——代码里用的是 tf.random_uniform(-1.0, 1.0),这个范围有时候偏大,换成 tf.truncated_normal(0.0, 0.1) 会更稳。另外确认输入数据没有包含异常长的文本,max_length 截断前如果遇到几十万字的脏数据,embedding lookup 那边可能出现维度异常。

6.2 placeholder 维度不匹配报错

现象:报错信息类似 ValueError: Shape must be rank 2 but is rank 3 for 'dense'。原因:最常见的是双向 LSTM 的输出拼接那步搞错了,直接用了单个方向的输出而没 concat;或者全连接层输入维度算错——RNN 拼接后维度是 hidden_dim * 2,CNN 池化后是 num_filters * len(filter_sizes),你对不上就会报这个错。解决:在 fc 层前面加一行 print(output.get_shape()),确认维度后再写 dense 层。如果用的是 TensorFlow 2.x 的 keras 接口,注意 bidirectional 层的输出形状和 TensorFlow 1.x 有差异,回归到 1.14 及以上版本和项目保持一样的环境最省事。

6.3 CPU 上训练慢到怀疑人生

现象:epoch 跑到一半,发现一个 epoch 要俩小时,整个训练要二十多个小时。原因:RNN 在 CPU 上就是慢,这是结构决定的,LSTM 每个时间步依赖前一步,没法并行。解决:能换 GPU 最好;没有 GPU 就把 max_length 从 600 降到 200,vocab_size 从 5000 降到 3000(只保留高频字),batch_size 调到 32。还有一个技巧:先用一小部分数据比如每类 500 条做 epoch=1 的调参试跑,确认参数没问题再全量跑。cnews 这个数据集是构造好的子集,已经是 6500 条每类的规模,你要是直接拿原始 THUCNews 几十万条数据跑而没缩小 max_length,在 CPU 上基本等不到结果。

6.4 预测脚本跑出来的结果永远是同一个类别

现象:predict.py 对任意输入文本,输出的分类都是同一个,而且 logits 差距巨大。原因:vocab 文件不对。如果训练完保存的词汇表是 A 版本的,预测时却用了 B 版本的(比如数据集更新后重新 build 了 vocab),字符到 id 的映射全部错位,embedding 查出来的向量是噪音,模型自然输出随机且集中的类别。解决:在 predict.py 里硬编码读取同一个 vocab_dir,不要用相对路径,不要靠手输文件名。把训练时输出的 vocab_size 和 vocab 文件的行数对比一下,如果差很多,说明 vocab 重建前后不一致。从那以后我每次训练和预测都强制走一遍同一个配置函数,把 vocab 路径、max_length、分类列表用同一个 config 对象传下去,再没出过这种翻车。

7. 进阶用法:把字符级 CNN 换成词级输入的两种改造路径

跑通这个项目之后,你会发现自己对文本分类的全链路已经有了手感。下一步可以考虑把它改造成词级输入——也就是引入分词器,把 vocab 从单字变成词语。这个改动的收益在长文本场景里比较明显:序列长度大幅缩短,训练速度能快一倍以上,准确率通常也会有小幅提升,因为词语携带的语义信息比单字更完整。改造路径有两条。第一条是在预处理环节加 jieba 分词,把 process_file 里的 for char in content 改成 for word in jieba.cut(content),同时把 build_vocab 里的统计粒度从单字换成词语;第二条是保持模型结构不变,只替换输入数据的构造方式,但要注意 max_length 的含义变了——原来是 600 个字符,现在是 600 个词,对应到字符数可能是原来的 1.5 到 2 倍,所以 max_length 可以适当调低到 200~300。如果你想把模型做得更强,还可以在 CNN 或 RNN 基础上加一层 attention——在 RNN 的输出上做加权平均而不是直接取最后一个时间步,用 tf.nn.softmax 对每个时间步的隐藏状态算权重再求和。这个改动大概几十行代码,但在长文本分类上通常能提 1~2 个点。我自己的习惯是准备一个 config.py 把 vocab_size、max_length、embedding_dim、filter_sizes 这些参数全部集中管理,训练和预测都从同一个 config 里读,这样排查问题的时候不用在三个脚本里来回找参数。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/7 1:27:53

STM32/Linux/电机控制:机器人工程师的三条高薪技术路径

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/7 1:27:52

嵌入式DMA原理与实战:从硬件本质到Linux驱动调优

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/7 1:27:51

分销返佣小程序源码拆解:从佣金分账到即时提现全链路

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/7 1:27:20

指令结构与编码:手把手设计16位CPU指令集

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/7 1:27:09

JSP+Servlet+JDBC构建洛阳旅游管理系统:从架构到部署的完整实践

简介&#xff1a;这份基于Java和JSP实现的洛阳旅游管理系统&#xff0c;是面向计算机专业毕业设计场景的完整项目&#xff0c;适合需要完成传统Web课题的学生参考。系统前端采用JSP、jQuery&#xff0c;后端由Servlet与JDBC支撑&#xff0c;集成网站首页、景点与资讯展示、景点…

作者头像 李华
网站建设 2026/10/7 1:25:58

医疗NLP诊断系统实战:从症状实体识别到疾病排序

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华