利用spaCy预测GitHub议题标签的项目实践

0 阅读15分钟

一个spaCy项目的记录:预测GitHub标签

理解烤箱的工作原理,并不意味着你学会了烹饪。同样,理解一个机器学习工具的语法,也不意味着你能够有意义地应用这项技术。因此,在这篇博客中,我想描述围绕创建一个spaCy项目的一些主题,这些主题不直接与语法相关,而是更普遍地与“进行”一个NLP项目的“行为”相关。

作为一个重点关注的示例用例,我们将预测GitHub议题的标签。目标不是讨论你需要运行的语法或命令。相反,这篇博客文章将描述一个项目如何开始和发展。我们将从一个公共数据集开始,但在项目进行过程中,我们还将构建一个自定义的标注界面,通过有选择地忽略部分数据来提高模型性能,甚至作为副产品为spaCy构建一个模型报告工具。

动机示例

最近加入某机构后,我注意到了在spaCy的GitHub仓库中标注议题所涉及的手动工作量。与每天维护问题跟踪器的同事交谈时,他们提到某种自动标签建议器可能有助于减少手动负担并加强标签标注的一致性。

这个仓库有超过5000个议题,其中大部分附有一个或多个由项目维护者添加的标签。例如,其中一些标签表示这是一个错误,而另一些则显示文档存在问题。

我与spaCy的核心开发者之一Sofie讨论了预测标签的想法。她对这个想法感到兴奋,并愿意作为“领域专家”支持这个项目,在我遗漏相关背景时解释项目的细节。

经过简短讨论,我确认了一些重要的项目属性。

  • 有一个合理的商业案例值得探索。即使不清楚我们应该对模型抱有什么期望,但我们确实认识到,拥有一个能够预测部分标签的模型可能会有所帮助。
  • 有一个包含大约5000个样本的已标注数据集可用,可以轻松从GitHub API下载。虽然标签可能不完全一致,但它们肯定足以作为起点。
  • 问题定义明确,因为我们可以将问题转化为文本分类任务。GitHub议题的内容包含我们需要分类的文本,这些文本需要分类为一组预先已知的非互斥类别。

这些信息足以让我开始工作。

第一步:项目设置

为了了解我管道中所需的步骤,我通常从在数字白板上绘图开始。这是我画的第一张图。

所需步骤的初步草图

更详细地描述每个步骤:

  1. 首先,一个脚本从GitHub API下载相关数据。
  2. 接下来,这些数据需要被清理和处理。在描述问题的句子之间,还会有Markdown和代码块,因此这里需要某种数据清理步骤。最终,这些数据需要转换为二进制的.spacy格式,以便我可以用它来训练spaCy模型。
  3. 最后一步是训练模型。超参数需要在一个配置文件中预先定义,训练好的spaCy模型随后会被保存在磁盘上。

这看起来足够简单,但“预处理”这一步感觉有点模糊。所以我扩展了这一步。

关于如何预处理的更多细节

我决定在预处理步骤中区分几个阶段。

  • 首先,我认为需要一个清理步骤。我希望能调试这个清理步骤,这意味着我还需要一个包含清理后数据的、可供检查的文件在磁盘上。通常在项目中,我也会用Prodigy重新标注一些数据,而一个清理过的.jsonl文件将允许我在管道开始处更新我的数据。
  • 接下来,我需要一个拆分步骤。我想我可能需要对训练集和验证集上的性能进行一些手动分析。这意味着我也需要这些文件的.jsonl变体保存在磁盘上。我不能使用.spacy文件的原因是.jsonl文件可以包含额外的元数据。例如,原始数据包含议题发布的日期,这对于完整性检查非常有用。
  • 最后,我需要一个转换步骤。准备好可能用于调查的中间文件后,我需要的最后一组文件是训练集和验证集的.spacy版本。

反思

当我对手头需要哪些功能有一个宏观概念时,实现项目所需的代码会容易得多。这就是为什么我喜欢在开始时进行“先在纸上解决”的练习。绘制的图表不仅帮助我思考需要什么;它们也成为了很好的文档,尤其是在与远程团队合作时。

绘图阶段完成后,我着手编写一个project.yml文件,定义了我需要的所有步骤。

project.yml文件是什么样的?

spaCy中的project.yml文件包含了一个spaCy项目中想要使用的所有步骤及其相关脚本的描述。下面的片段省略了一些细节,但我开始时最重要的命令是:

workflows:
  all:
    - download
    - clean
    - split
    - convert
    - train
  preprocess:
    - clean
    - split
    - convert

commands:
  - name: 'download'
    help: '从Github仓库抓取spaCy议题'
    script:
      - 'python scripts/download.py raw/github.jsonl'
  - name: 'clean'
    help: '清理原始数据以供检查。'
    script:
      - 'python scripts/clean.py raw/github.jsonl raw/github_clean.jsonl'
  - name: 'split'
    help: '将下载的数据拆分为训练/开发集。'
    script:
      - 'python scripts/split.py raw/github_clean.jsonl assets/train.jsonl assets/valid.jsonl'
  - name: 'convert'
    help: "将数据转换为spaCy的二进制格式"
    script:
      - 'python scripts/convert.py en assets/train.jsonl corpus/train.spacy'
      - 'python scripts/convert.py en assets/valid.jsonl corpus/dev.spacy'
  - name: 'train'
    help: '训练文本分类模型'
    script:
      - 'python -m spacy train configs/config.cfg --output training/ --paths.train corpus/train.spacy --paths.dev corpus/dev.spacy --nlp.lang en'

文件夹结构是什么样的?

在处理项目文件的同时,我还创建了一个文件夹结构。


┣━━ 📂 assets
┃   ┣━━ 📄 github-dev.jsonl (7.7 MB)
┃   ┗━━ 📄 github-train.jsonl (12.5 MB)
┣━━ 📂 configs
┃   ┗━━ 📄 config.cfg (2.6 kB)
┣━━ 📂 corpus
┃   ┣━━ 📄 dev.spacy (4.0 MB)
┃   ┗━━ 📄 train.spacy (6.7 MB)
┣━━ 📂 raw
┃   ┣━━ 📄 github.jsonl (10.6 MB)
┃   ┗━━ 📄 github_clean.jsonl (20.3 MB)
┣━━ 📂 recipes
┣━━ 📂 scripts
┣━━ 📂 training
┣━━ 📄 project.yml (4.1 kB)

┗━━ 📄 requirements.txt (95 bytes)

training文件夹将包含训练好的spaCy模型。recipes文件夹将包含我为Prodigy可能添加的任何自定义配方,scripts文件夹将包含处理我在白板上绘制的逻辑的所有脚本。

第二步:从第一次运行中学习

一切就绪后,我花了几个小时实现所需的脚本。目标是首先构建一个从数据到模型的完全功能循环。当一个方法从头到尾都可用时,迭代起来会容易得多。

这意味着我对数据清理也只做了最低限度的处理。模型接收来自GitHub议题的原始Markdown文本,其中包括原始代码块。我知道这不是最优的,但我真的希望先有一个可用的管道,然后再担心任何细节。

第一个模型训练完成后,我开始深入研究模型和数据。从这项工作中,我学到了几个重要的教训。首先,spaCy项目中有113个标签,其中很多使用不多。特别是与特定自然语言相关的标签可能只有几个样本。

其次,列出的某些标签无论如何都不会相关,无论有多少训练样本。例如,v1标签表示该议题与一个不再维护的spaCy版本相关。同样,wontfix标签表示项目维护者的一个有意决定,而做出该决定的原因通常不会出现在议题的第一个帖子中。任何算法实际上都无法预测这种“元”标签。

最后,spaCy的预测值得进一步研究。spaCy分类器模型预测一个包含标签/置信度对的字典。但不同标签之间的置信度值往往差异很大。

我想为下一次与Sofie的会议做准备。所以下一步是清点一下我们可能从当前设置中得到什么。

第三步:构建自己的工具

为了更好地理解模型性能,我决定构建一个小型仪表板,允许我单独检查每个标签预测的性能。如果我理解标签的阈值与精确率/召回率性能之间的关系,那么我就可以在与Sofie的对话中利用这一点,以确认模型是否有用。

我继续向我的project.yml文件添加了一个脚本,该脚本在一个静态HTML文件中生成一些交互式图表。这样,每当我重新训练模型时,我就能够自动生成一个交互式的index.html文件,让我可以调整阈值。

下面是仪表板为docs标签显示的内容。

两张折线图,一张用于训练集,另一张用于验证集。这些折线图显示了所选阈值值的精确率/召回率/准确率指标。这些图表使我能够轻松地在每个标签的精确率和召回率之间进行权衡。

有了这个仪表板,是时候与Sofie核对,讨论哪些标签可能最值得进一步探索。

第四步:汇报进展

随后与Sofie的会议令人兴奋。作为维护者,她有很多我不知道的隐性知识,但因为我此时更接近数据,我也知道一些她不知道的事情。交流非常富有成果,并且做出了一些关键决定。

首先,Sofie指出我是随机拆分训练/测试集的,这并不理想,我应该将最近的议题数据作为我的测试集。主要原因是问题跟踪器的使用方式随着时间的推移已经改变了几次。项目随着时间的推移获得了新标签,新维护者加入项目时也产生了新惯例,而且仓库最近还添加了GitHub讨论功能,这导致许多议题变成了讨论项。

其次,Sofie同意我们应该只关注标签的一个子集。我们决定只查看出现至少180次的标签。

最后,我问Sofie我可以多大程度上信任训练数据。经过简短讨论,我们一致认为最好对一些样本进行双重检查,因为有可能某些标签分配得不一致。虽然spaCy核心团队多年来非常稳定,但做支持的具体人员随着时间的推移有了一些变化。我也发现了一些没有任何标签附着的议题,Sofie同意这些是首先检查的好候选。

我同意将所有这些事项作为接下来的步骤来处理。

第五步:从标注中汲取的教训

我调整了已有的脚本,继续为Prodigy创建自定义标注配方。我希望议题的渲染方式与在GitHub上呈现的一样,所以我努力集成了GitHub用于渲染Markdown的CSS。

看起来就像Github!

我对议题的渲染方式感到满意,但我很快注意到其中一些议题非常长。我很在意屏幕空间,这就是为什么我做了一些额外的CSS工作,使整个界面以两列布局加载。

两列布局能更好地利用屏幕空间

这种两列布局对我的屏幕来说刚刚好。图像也会渲染,这是设置的一个不错的好处。

现在意味着可以开始标注了。所以我尝试了几种策略来检查数据。

  1. 我首先查看了没有任何标签附着的示例。这些示例中有许多结果是关于库的使用(和非使用)的简短问题。这些可能包括“我能在移动设备上运行spaCy吗?”这样的内容。通常涉及分词器或词性标注器做出了意外行为的情况。这些示例中有许多并未描述实际的错误,而是描述了用户的期望,因此也属于“用法”标签。
  2. 接下来,我决定通过随机抽样来检查示例。通过这种方式标注,我注意到许多带有“bug”标签的示例缺少一个能突出显示代码库相关部分的关联标签。经过一些挖掘,我了解到,例如,feat / matcher标签的引入时间比bug标签晚得多。这意味着如果议题出现在2018年之前,数据集中可能会缺少许多相关标签。
  3. 最后,我想再尝试一件事。根据之前的练习,我觉得标签的存在比标签的缺失更可靠。所以我使用训练好的模型,让它尝试预测feat / matcher标签。如果某个示例缺少这个标签但模型预测了它,那么就值得双重检查。有41个这样的示例,而标注了feat / matcher标签的实例有186个。标注后,我确认其中37/41个错误地缺少了该标签。结果还发现,这37个示例中有29个早于2018年2月,也就是feat / matcher标签被引入的时间。

鉴于这些教训,我决定减少我的训练集。我只保留2018年2月之后的示例以提高一致性。仅仅这样做就对模型性能产生了显著影响。

EpochStepScore BeforeScore After
50057.9761.31
100063.1066.61
150064.8570.99
200067.4773.65
250071.5375.77
300071.7977.93
350073.2079.11

这是一个有趣的观察;我只修改了我的训练数据,而没有更改用于评估的测试数据。这意味着我通过迭代数据而不是模型来提高了模型性能。

在再次训练模型之前,我决定标注一些可能与feat / matcher标签相关的更多示例。我通过查找正文中包含“matcher”一词的议题来创建子集。这给了我另外50个示例。

第六步:另一份进展报告

再次训练模型并检查了阈值报告后,我认为我达到了一个不错的里程碑。我聚焦于feat / matcher标签,发现我可以实现:

  • 当我选择0.5的阈值时,达到75%的精确率 / 75%的召回率。
  • 当我选择0.84的阈值时,达到91%的精确率 / 62%的召回率。

这些指标绝非“最先进”的结果,但它们足够具体,可以做出决定。

我将这些结果呈现给Sofie,她很高兴能够考虑阈值选项。这意味着我们可以调整预测而不必重新训练新模型,即使在原始0.5阈值下,数字看起来已经相当不错。就feat / matcher标签而言,Sofie认为这个练习是成功的。

项目可以进一步推进。我们思考接下来要优先处理哪些其他标签,并开始考虑如何在生产环境中运行模型。但构建模型的第一个练习已经完成,这意味着我们可以回顾并反思一路上学到的一些经验教训。

结论

在这篇博客文章中,我描述了一个spaCy项目如何演变的示例。虽然我们从一个明确定义的问题开始,但很难预测我们会采取哪些步骤来改进模型并达到现在的状态。特别是:

  • 我们分析了标签数据集,这告诉我们某些日期可以被排除,以确保数据一致性。
  • 我们创建了一个自定义的标注界面,使用了来自GitHub的CSS,这使得在我们的训练数据中改进和重新标注示例变得方便。
  • 我们制作了一个针对我们分类任务的报告,这允许我们选择适合需求的阈值。

所有这些发展都直接受到我们试图解决的分类问题的启发。对于一些子问题,我们能够使用预先存在的工具,但投入精力制作针对特定任务的工具是完全合理的。你甚至可以说,花时间做这件事带来了决定性的不同。

想象一下,如果我们把所有的精力都放在模型上。如果我们尝试了更多的超参数,真的很有可能我们会处于更好的状态吗?

我喜欢当前里程碑的原因在于,问题因此得到了更好的理解。这也是为什么我与Sofie的互动如此有价值!如果你能与领域专家讨论里程碑,定制解决方案会容易得多。

这个教训也反映了我们通过定制管道服务在客户项目中学到的一些经验。退一步考虑一种不那么通用、更定制化的方法确实很有帮助。NLP中的许多问题无法用通用工具解决,它们需要一个量身定制的解决方案。

哦,还有一件事……

我在这个项目中制作的自定义仪表板被证明非常有用。我也认为这个工具足够通用,对其他spaCy用户也有用。这就是我决定将其开源的原因。你今天就可以pip install spacy-report,为你自己的项目探索阈值!