• 【Spark NLP】第 13 章:构建知识库


      🔎大家好,我是Sonhhxg_柒,希望你看完之后,能对你有所帮助,不足请指正!共同学习交流🔎

    📝个人主页-Sonhhxg_柒的博客_CSDN博客 📃

    🎁欢迎各位→点赞👍 + 收藏⭐️ + 留言📝​

    📣系列专栏 - 机器学习【ML】 自然语言处理【NLP】  深度学习【DL】

     🖍foreword

    ✔说明⇢本人讲解主要包括Python、机器学习(ML)、深度学习(DL)、自然语言处理(NLP)等内容。

    如果你对这个系列感兴趣的话,可以关注订阅哟👋

    文章目录

    问题陈述和约束

    计划项目

    设计解决方案

    实施解决方案

    测试和测量解决方案

    业务指标

    以模型为中心的指标

    基础设施指标

    过程指标

    审查

    结论


    这个应用程序是关于组织信息并使其易于人类和计算机访问的。这称为知识库。近几十年来,随着焦点从“专家系统”转移到统计机器学习方法,NLP 领域知识库的受欢迎程度已经减弱。

    专家系统是一个试图利用知识做出决策的系统。这些知识是关于实体、实体之间的关系和规则的。通常,专家系统具有推理引擎,允许软件利用知识库做出决策。这些有时被描述为 if-then 规则的集合。然而,这些系统比这复杂得多。对于当时的技术而言,知识库和规则集可能非常庞大,因此推理引擎需要能够有效地评估许多逻辑语句。

    一般来说,专家系统有许多可以采取的行动。它应该采取哪些行动是有规则的。当需要采取行动时,系统会收集一组语句,并且必须使用这些语句来确定最佳行动。例如,假设我们有一个用于控制房屋温度的专家系统。我们需要能够根据温度和时间做出决定。每当系统决定切换加热器、空调或什么都不做时,它必须获取当前温度(或者可能是温度测量值的集合)和当前时间,并结合规则集来确定要采取的行动. 这个系统有少量的实体——温度和时间。想象一下,如果一个系统有数千个实体、多种关系和不断增长的规则集。

    在本章中,我们将建立一个知识库。我们想要一个用于从 wiki 构建知识库的工具和一个用于查询知识库的工具。该系统现在应该可以安装在一台机器上。我们还希望能够使用新类型的实体和关系来更新知识库。这样的系统可以被领域专家用于探索一个主题,或者它可以与专家系统集成。这意味着它应该具有人类可用的界面和响应式 API。

    我们的虚构场景是一家正在构建机器学习平台的公司。该公司主要向其他企业销售。销售工程师有时会与系统的当前状态不同步。工程师很好,会在适当的时候更新 wiki,但销售工程师很难跟上最新状态。销售工程师为工程师创建帮助票,以帮助他们更新销售演示。工程师不喜欢这样。所以这个应用程序将用于创建一个知识库,使销售工程师更容易检查可能发生的变化。

    问题陈述和约束

    1. 我们试图解决的问题是什么?

      我们想要一个 wiki 并产生一个知识库。还应该有人类和其他软件查询知识库的方法。我们可以假设知识库适合单台机器。

    2. 有哪些限制条件?

      • 知识库构建器应该易于更新。配置新类型的关系应该很容易。

      • 存储解决方案应该允许我们轻松添加新的实体和关系。
      • 回答查询将需要少于 50GB 的磁盘空间和少于 16GB 的内存。
      • 应该有一个获取相关实体的查询。例如,在 wiki 文章的末尾通常有指向相关页面的链接。“获取相关”查询应该获取这些实体。
      • “获取相关”查询应该少于 500 毫秒。

    3. 我们如何解决约束问题?

      • 知识库构建器可以是获取 wiki 转储并处理 XML 和文本的脚本。这是我们可以在更大的 Spark 管道中使用 Spark NLP 的地方。

      • 我们的构建脚本应该监控资源,在我们接近规定的限制时发出警告。
      • 我们需要一个数据库来存储我们的知识库。有很多选择。我们将使用 Neo4j,一个图形数据库。Neo4j 也比较有名。还有其他可能的解决方案,但图形数据库固有地以促进知识库的方式构造数据。
      • Neo4j 的另一个好处是它带有一个供人类查询的 GUI 和一个用于编程查询的 REST API。

    计划项目

    让我们定义验收标准。我们需要一个执行以下操作的脚本:

    • 获取 wiki 转储,通常是压缩的 XML 文件
    • 提取实体,例如文章标题
    • 提取关系,例如文章之间的链接
    • 在 Neo4J 中存储实体和关系
    • 如果我们生成太多数据,则发出警告

    我们需要执行以下操作的服务:

    • 允许对给定实体进行“获取相关”查询——结果必须至少是实体文章中链接的文章
    • 在 500 毫秒内执行“获取相关”查询
    • 有一个人类可用的前端
    • 有一个 REST API
    • 运行所需内存少于 16GB

    这有点类似于第 12 章中的应用程序。但是,与该章不同的是,该模型不是机器学习模型,而是数据模型。我们有一个可以构建模型的脚本,但现在我们还想要一种服务模型的方法。另一个重要的区别是知识库没有简单的分数(例如,F1 分数)。这意味着我们将不得不更多地考虑指标。

    设计解决方案

    所以我们需要启动Neo4J。一旦你安装了它,你应该能够去 localhost:7474 的 UI。

    由于我们使用的是现成的解决方案,因此我们不会过多地研究图形数据库。这是重要的事实。

    构建图形数据库以将数据存储为节点和节点之间的边。在这种情况下,节点的含义通常是某种实体,而边缘是某种关系。可以有不同类型的节点和不同类型的关系。在数据库之外,图形数据可以很容易地存储在 CSV 中。节点会有 CSV。此 CSV 将有一个 ID 列、某种名称和属性——取决于类型。边是相似的,除了边的行也将具有边连接的两个节点的 ID。我们不会存储属性。

    让我们考虑一个简单的场景,我们想要存储有关书籍的信息。在这个场景中,我们有三种实体:作者、书籍和流派。存在三种关系:作者一本书,作者流派作者,一本书属于流派。对于 Neo4j,这些数据可以存储在六个 CSV 中。实体是图的节点,关系是边,如图13-1所示。

    图 13-1。简单图形示例

    由于我们无法访问公司的内部 wiki,因此我们将使用实际的 Wikipedia 转储。但是,我们将使用 Simple English wikidump,而不是获得完整的英语语言转储,这将是巨大的。

    简单英语是英语的一个子集。它使用了大约 1,500 个单词,不包括专有名词和一些技术术语。这对我们很有用,因为这将帮助我们简化我们需要编写的代码。如果这是一个真正的公司 wiki,可能需要进行几次数据清理迭代。看一下简单英语维基百科的转储。

    这是我们的计划:

    1. 获取数据
    2. 探索数据
    3. 解析 wiki 中的实体和关系
    4. 将实体和关系保存在 CSV 中
    5. 将 CSV 加载到 Neo4J

    实施解决方案

    首先,让我们加载数据。大多数 wikidump 以 bzip2 压缩 XML 文件的形式提供。幸运的是,Spark 有能力处理这种数据。让我们加载它。

    1. import json
    2. import re
    3. import pandas as pd
    4. import sparknlp
    5. from pyspark.ml import Pipeline
    6. from pyspark.sql import SparkSession, Row
    7. from pyspark.sql.functions import lit, col
    8. import sparknlp
    9. from sparknlp import DocumentAssembler, Finisher
    10. from sparknlp.annotator import *
    1. packages = [
    2. 'JohnSnowLabs:spark-nlp:2.2.2',
    3. 'com.databricks:spark-xml_2.11:0.6.0'
    4. ]
    5. spark = SparkSession.builder \
    6. .master("local[*]") \
    7. .appName("Knowledge Graph") \
    8. .config("spark.driver.memory", "12g") \
    9. .config("spark.jars.packages", ','.join(packages)) \
    10. .getOrCreate()

    为了给 Spark 一个解析 XML 的提示,我们需要配置它rootTag是什么——包含我们所有“行”的元素的名称。我们还需要配置rowTag代表我们行的元素的名称。

    1. df = spark.read\
    2. .format('xml')\
    3. .option("rootTag", "mediawiki")\
    4. .option("rowTag", "page")\
    5. .load("simplewiki-20191020-pages-articles-multistream.xml.bz2")\
    6. .persist()

    现在,让我们看看架构是什么样的。

    df.printSchema()
    1. root
    2. |-- id: long (nullable = true)
    3. |-- ns: long (nullable = true)
    4. |-- redirect: struct (nullable = true)
    5. | |-- _VALUE: string (nullable = true)
    6. | |-- _title: string (nullable = true)
    7. |-- restrictions: string (nullable = true)
    8. |-- revision: struct (nullable = true)
    9. | |-- comment: struct (nullable = true)
    10. | | |-- _VALUE: string (nullable = true)
    11. | | |-- _deleted: string (nullable = true)
    12. | |-- contributor: struct (nullable = true)
    13. | | |-- _VALUE: string (nullable = true)
    14. | | |-- _deleted: string (nullable = true)
    15. | | |-- id: long (nullable = true)
    16. | | |-- ip: string (nullable = true)
    17. | | |-- username: string (nullable = true)
    18. | |-- format: string (nullable = true)
    19. | |-- id: long (nullable = true)
    20. | |-- minor: string (nullable = true)
    21. | |-- model: string (nullable = true)
    22. | |-- parentid: long (nullable = true)
    23. | |-- sha1: string (nullable = true)
    24. | |-- text: struct (nullable = true)
    25. | | |-- _VALUE: string (nullable = true)
    26. | | |-- _space: string (nullable = true)
    27. | |-- timestamp: string (nullable = true)
    28. |-- title: string (nullable = true)

    这有点复杂,所以我们应该尝试简化。让我们看看我们有多少文件。

    df.count()
    284812

    让我们看一下“Paper”页面,以便我们了解如何简化数据。

    1. row = df.filter('title = "Paper"').first()
    2. print('ID', row['id'])
    3. print('Title', row['title'])
    4. print()
    5. print('redirect', row['redirect'])
    6. print()
    7. print('text')
    8. print(row['revision']['text']['_VALUE'])
    1. ID 3319
    2. Title Paper
    3. redirect None
    4. text
    5. [[File:...
    6. [[File:...
    7. [[File:...
    8. [[File:...
    9. [[File:...
    10. [[File:...
    11. Modern '''paper''' is a thin [[material]] of (mostly)
    12. [[wood fibre]]s pressed together. People write on paper, and
    13. [[book]]s are made of paper. Paper can absorb [[liquid]]s such as
    14. [[water]], so people can clean things with paper.
    15. The '''pulp and paper industry''' comprises companies that use wood as
    16. raw material and produce [[Pulp (paper)|pulp]], paper, board and other
    17. cellulose-based products.
    18. == Paper making ==
    19. Modern paper is normally ...
    20. ==Related pages==
    21. * [[Paper size]]
    22. * [[Cardboard]]
    23. == References ==
    24. {{Reflist}}
    25. [[Category:Basic English 850 words]]
    26. [[Category:Paper| ]]
    27. [[Category:Writing tools]]

    看起来文本存储在revision.text._VALUE.似乎有一些特殊条目,即categoriesredirects。在大多数 wiki 中,页面被组织成不同的类别。页面通常属于多个类别。这些类别有自己的页面链接回文章。重定向是从文章的备用名称指向实际条目的指针。

    让我们看一些类别。

    1. df.filter('title RLIKE "Category.*"').select('title')\
    2. .show(10, False, True)
    1. -RECORD 0--------------------------
    2. title | Category:Computer science
    3. -RECORD 1--------------------------
    4. title | Category:Sports
    5. -RECORD 2--------------------------
    6. title | Category:Athletics
    7. -RECORD 3--------------------------
    8. title | Category:Body parts
    9. -RECORD 4--------------------------
    10. title | Category:Tools
    11. -RECORD 5--------------------------
    12. title | Category:Movies
    13. -RECORD 6--------------------------
    14. title | Category:Grammar
    15. -RECORD 7--------------------------
    16. title | Category:Mathematics
    17. -RECORD 8--------------------------
    18. title | Category:Alphabet
    19. -RECORD 9--------------------------
    20. title | Category:Countries
    21. only showing top 10 rows

    现在让我们看看重定向。看起来重定向指向的重定向目标存储在redirect._title.

    1. df.filter('redirect IS NOT NULL')\
    2. .select('redirect._title', 'title')\
    3. .show(1, False, True)
    1. -RECORD 0-------------
    2. _title | Catharism
    3. title | Albigensian
    4. only showing top 1 row

    这本质上给了我们一个同义词关系。因此,我们的实体将是文章的标题。我们的关系将是重定向,链接将位于页面的相关部分。首先让我们获取我们的实体。

    1. entities = df.select('title').collect()
    2. entities = [r['title'] for r in entities]
    3. entities = set(entities)
    4. print(len(entities))
    284812

    我们可能想引入同类别关系,所以我们也提取类别。

    1. categories = [e for e in entity if e.startswith('Category:')]
    2. entity = [e for e in entity if not e.startswith('Category:')]

    现在,让我们获取重定向。

    1. redirects = df.filter('redirect IS NOT NULL')\
    2. .select('redirect._title', 'title').collect()
    3. redirects = [(r['_title'], r['title']) for r in redirects]
    4. print(len(redirects))
    63941

    现在我们可以从revision.text._VALUE.

    1. data = df.filter('redirect IS NULL').selectExpr(
    2. 'revision.text._VALUE AS text',
    3. 'title'
    4. ).filter('text IS NOT NULL')

    要获取相关链接,我们需要知道我们在哪个部分。所以让我们将文本分成几个部分。然后我们可以使用RegexMatcher注释器来识别链接。查看数据,部分看起来就像== Paper making ==我们在前面的示例中看到的那样。让我们为此定义一个正则表达式,增加额外空格的可能性。

    section_ptn = re.compile(r'^ *==[^=]+ *== *$')

    现在,我们将定义一个函数,该函数将对数据进行分区并为这些部分生成新行。我们需要跟踪文章标题、部分和部分的文本。

    1. def sectionize(rows):
    2. for row in rows:
    3. title = row['title']
    4. text = row['text']
    5. lines = text.split('\n')
    6. buffer = []
    7. section = 'START'
    8. for line in lines:
    9. if section_ptn.match(line):
    10. yield (title, section, '\n'.join(buffer))
    11. section = line.strip('=').strip().upper()
    12. buffer = []
    13. continue
    14. buffer.append(line)

    现在我们将调用mapPartitions创建一个新的RDD并将其转换为DataFrame.

    1. sections = data.rdd.mapPartitions(sectionize)
    2. sections = spark.createDataFrame(sections, \
    3. ['title', 'section', 'text'])

    让我们看看最常见的部分。

    1. sections.select('section').groupBy('section')\
    2. .count().orderBy(col('count').desc()).take(10)
    1. [Row(section='START', count=115586),
    2. Row(section='REFERENCES', count=32993),
    3. Row(section='RELATED PAGES', count=8603),
    4. Row(section='HISTORY', count=6227),
    5. Row(section='CLUB CAREER STATISTICS', count=3897),
    6. Row(section='INTERNATIONAL CAREER STATISTICS', count=2493),
    7. Row(section='GEOGRAPHY', count=2188),
    8. Row(section='EARLY LIFE', count=1935),
    9. Row(section='CAREER', count=1726),
    10. Row(section='NOTES', count=1724)]

    说白了,START是最常见的,因为它捕获了文章开头和第一部分之间的文本,所以几乎所有的文章都会有这个。这是来自维基百科,所以REFERENCES是下一个最常见的。它看起来RELATED PAGES只出现在 8,603 篇文章中。现在,我们将使用 Spark-NLP 从文本中提取所有链接。

    1. %%writefile wiki_regexes.csv
    2. \[\[[^\]]+\]\]~link
    3. \{\{[^\}]+\}\}~anchor
    Overwriting wiki_regexes.csv
    1. assembler = DocumentAssembler()\
    2. .setInputCol('text')\
    3. .setOutputCol('document')
    4. matcher = RegexMatcher()\
    5. .setInputCols(['document'])\
    6. .setOutputCol('matches')\
    7. .setStrategy("MATCH_ALL")\
    8. .setExternalRules('wiki_regexes.csv', '~')
    9. finisher = Finisher()\
    10. .setInputCols(['matches'])\
    11. .setOutputCols(['links'])
    12. pipeline = Pipeline()\
    13. .setStages([assembler, matcher, finisher])\
    14. .fit(sections)
    extracted = pipeline.transform(sections)

    现在,我们可以根据任何地方出现的链接来定义关系。目前,我们将仅使用相关链接。

    1. links = extracted.select('title', 'section','links').collect()
    2. links = [(r['title'], r['section'], link) for r in links for link in r['links']]
    3. links = list(set(links))
    4. print(len(links))
    4012895
    1. related = [(l[0], l[2]) for l in links if l[1] == 'RELATED PAGES']
    2. related = [(e1, e2.strip('[').strip(']').split('|')[-1]) for e1, e2 in related]
    3. related = list(set([(e1, e2) for e1, e2 in related]))
    4. print(len(related))
    20726

    现在,我们已经提取了我们的实体、重定向和相关链接。让我们为它们创建 CSV。

    1. entities_df = pd.Series(entities, name='entity').to_frame()
    2. entities_df.index.name = 'id'
    3. entities_df.to_csv('wiki-entities.csv', index=True, header=True)
    e2id = entity_df.reset_index().set_index('entity')['id'].to_dict()
    1. redirect_df = []
    2. for e1, e2 in redirects:
    3. if e1 in e2id and e2 in e2id:
    4. redirect_df.append((e2id[e1], e2id[e2]))
    5. redirect_df = pd.DataFrame(redirect_df, columns=['id1', 'id2'])
    6. redirect_df.to_csv('wiki-redirects.csv', index=False, header=True)
    1. related_df = []
    2. for e1, e2 in related:
    3. if e1 in e2id and e2 in e2id:
    4. related_df.append((e2id[e1], e2id[e2]))
    5. related_df = pd.DataFrame(related_df, columns=['id1', 'id2'])
    6. related_df.to_csv('wiki-related.csv', index=False, header=True)

    现在我们有了 CSV,我们可以/var/lib/neo4j/import/使用以下命令将它们复制到并导入它们:

    • 加载实体

      1. LOAD CSV WITH HEADERS FROM "file:/wiki-entities.csv" AS csvLine
      2. CREATE (e:Entity {id: toInteger(csvLine.id), entity: csvLine.entity})
    • 加载“重定向”关系

      1. USING PERIODIC COMMIT 500
      2. LOAD CSV WITH HEADERS FROM "file:///wiki-redirected.csv" AS csvLine
      3. MATCH (entity1:Entity {id: toInteger(csvLine.id1)}),(entity2:Entity {id: toInteger(csvLine.id2)})
      4. CREATE (entity1)-[:REDIRECTED {conxn: "redirected"}]->(entity2)
    • 加载“相关”关系

      1. USING PERIODIC COMMIT 500
      2. LOAD CSV WITH HEADERS FROM "file:///wiki-related.csv" AS csvLine
      3. MATCH (entity1:Entity {id: toInteger(csvLine.id1)}),(entity2:Entity {id: toInteger(csvLine.id2)})
      4. CREATE (entity1)-[:RELATED {conxn: "related"}]->(entity2)Let's go see what we can query. We will get all entities related to "Language" and related to entities that are related to Language (i.e., second-order relations).

    让我们看看我们可以查询什么。我们将得到所有与“Language”相关的实体,以及与“Language”相关的实体(即二阶关系)。

    1. import requests
    2. query = '''
    3. MATCH (e:Entity {entity: 'Language'})
    4. RETURN e
    5. UNION ALL
    6. MATCH (:Entity {entity: 'Language'})--(e:Entity)
    7. RETURN e
    8. UNION ALL
    9. MATCH (:Entity {entity: 'Language'})--(e1:Entity)--(e:Entity)
    10. RETURN e
    11. '''
    12. payload = {'query': query, 'params': {}}
    13. endpoint = 'http://localhost:7474/db/data/cypher'
    14. response = requests.post(endpoint, json=payload)
    15. response.status_code
    200
    1. related = json.loads(response.content)
    2. related = [entity[0]['data']['entity']
    3. for entity in related['data']]
    4. related = sorted(related)
    5. related
    1. 1989 in movies
    2. Alphabet
    3. Alphabet (computer science)
    4. Alphabet (computer science)
    5. American English
    6. ...
    7. Template:Jctint/core
    8. Testing English as a foreign language
    9. Vowel
    10. Wikipedia:How to write Simple English pages
    11. Writing

    我们已经处理了一个 wikidump 并在 Neo4j 中创建了一个基本图。该项目的下一步将是提取更多的节点类型和关系。找到一种将重量附加到边缘的方法也会很好。这将使我们能够从查询中返回更好的结果。

    测试和测量解决方案

    我们现在有了一个初始实现,让我们来看看指标。

    业务指标

    这将取决于此应用程序的特定用例。如果这个知识库是用来组织公司内部信息的,那么我们可以看看使用率。这不是一个很好的指标,因为它并没有告诉我们该系统实际上正在帮助业务——只是它正在被使用。让我们考虑一个假设的场景。

    使用我们的示例,销售工程师可以查询他们想要演示的功能并获取相关功能。希望这会减少帮助票。这是我们可以监控的业务级指标。

    如果我们实现了这个系统并且没有看到业务指标有足够的变化,我们仍然需要指标来帮助我们了解问题是出在应用程序的基本思想上还是出在知识库的质量上。

    以模型为中心的指标

    衡量一个集合的质量并不像衡量一个分类器那么简单。让我们考虑一下我们对知识库中应该包含什么的直觉,并将这些直觉转化为指标。

    • 稀疏性与密度:如果太多实体与任何其他实体没有关系,它们会降低知识库的有用性;同样,无处不在的关系会耗费资源并且几乎没有收益。以下是一些可用于衡量连接性的简单指标。
      • 每个实体的平均关系数
      • 没有关系的实体的比例
      • 关系出现与全连接图的比率
    • 人们查询的实体和关系是我们必须关注的。同样,几乎从未使用过的关系可能是多余的。部署系统并记录查询后,我们可以监视以下内容以了解使用情况。
      • 未找到实体的查询数
      • 一个时间段(天、周、月)内未被查询的关系数

    输出 CSV 的中间步骤的好处是我们不需要从数据库中进行大量提取——我们可以使用 CSV 数据计算这些图形指标。

    现在我们对如何衡量知识库的质量有了一些了解,让我们来谈谈衡量基础设施。

    基础设施指标

    我们将要确保我们的单服务器方法是足够的。对于一家中小型公司来说,这应该没问题。如果公司很大,或者应用程序的用途更广泛,我们会考虑复制。也就是说,我们将有多个带有数据库的服务器,并且用户将通过负载平衡器重定向。

    使用 Neo4j,您可以通过查询来查看系统信息:sysinfo。这将为您提供有关正在使用的数据量的信息。

    对于这样的应用程序,您可能希望在查询时监控响应时间,并在添加新实体或关系时监控更新时间。

    过程指标

    在通用流程指标之上,对于此项目,您希望监控某人能够更新图表需要多长时间。有几种方法可能会更新此图表。

    • 定期更新以捕获 wiki 更新
    • 添加新的关系类型
    • 向实体或关系添加属性

    其中第一个是最重要的监控。这个应用程序的重点是让销售工程师保持最新,因此这些数据必须保持最新。理想情况下,应该监控这个过程。接下来的两个对于监控很重要,因为这个项目的希望是减少开发人员和数据科学家的工作量。我们不想用维护这个应用程序来代替支持销售工作所需的工作。

    审查

    第 12 章中的许多审查步骤也适用于该应用程序。您仍然需要进行架构审查和代码审查。在这种情况下,模型审查看起来会有所不同。您将查看数据模型,而不是查看机器学习模型。在构建知识图谱时,您需要平衡性能需求,同时以对领域有意义的方式构建数据。这不是一个新问题。事实上,传统的关系数据库有很多方法可以平衡这些需求。

    您可以注意一些常见的结构性问题。第一,有一个节点类型只有一两个属性;您可能需要考虑使其成为它连接到的节点的属性。例如,我们可以定义一个名称类型的节点并让它连接到实体,但这会使图形不必要地复杂化。

    这种应用程序的部署会更容易,除非它是面向客户的。您的备份计划应该更关注与用户的沟通,而不是替换“更简单”的版本。

    结论

    在本章中,我们探索了创建一个不基于机器学习的应用程序。我们可以用 NLP 做的最有价值的事情之一就是让人们更容易访问里面的信息。当然,这可以通过构建模型来完成,但也可以通过组织信息来完成。在第 14 章中,我们将研究构建一个使用搜索来帮助人们组织和访问文本信息的应用程序。

  • 相关阅读:
    JAVA 0基础 转义字符
    GoLong的学习之路(十一)语法之标准库 fmt.Printf的使用
    Android 调试桥——ADB
    PMS150C应广单片机开发案例
    青海特色美食制作工艺数字化保护平台
    VScode 安装插件后依然不能理解lombok注释的问题
    【笔记】软件测试的艺术
    数据分析相关知识整理_--秋招面试版
    Golang骚操作——使用runtime私有函数
    《深入浅出.NET框架设计与实现》阅读笔记(四)
  • 原文地址:https://blog.csdn.net/sikh_0529/article/details/127569535