一个简单可扩展的python数据处理框架

0. 背景

知识图谱构建是指从原始数据到结构化图谱的数据抽取、转换的过程。由于原始数据来源众多、结构不统一、数据语义不一致,因此在整个知识图谱系统的建设过程中, 往往是最为复杂的环节,但又是必不可少的环节。

GoIN系统是由中科院计算所天玑团队研发的知识图谱分析产品,核心数据处理流程就是知识抽取和图谱构建。

在之前的产品设计中,这个过程被设计为交互式的,用户通过界面上传数据、配置规则,调用后台的抽取服务最终形成图谱,这样最大的问题是图谱规模受到限制。另外一个问题是抽取服务中很多业务逻辑是固化的,隐藏在零碎的if-else语句中,如果要增加新的逻辑处理会非常困难,也就是扩展性不好。

1. 需求分析

在前面的背景介绍中总体介绍了这项工作的需求来源,整理一下就是:

  1. 需要一套离线程序进行知识图谱构建,从而能够实现自动化、规模化处理
  2. 图谱构建业务逻辑比较容易扩展,能够轻松地根据具体需求进行配置和开发
  3. 支持多种数据来源和数据类型,包括目前产品规划的结构化数据、半结构化数据和非结构化数据
  4. 支持流程可配置,根据具体业务处理需要进行组装

2. 设计思路

根据需求,首先设计一套处理框架,将主要的处理过程划分为框架中的一个或多个步骤,通过配置和框架程序将这些步骤串联起来,就可以提供较好的扩展性。因为每个步骤在理论上就可以被置换、被改进、被重组。也很容易支持第3点需求,根据具体需要实现就可以。

通过对框架进行并行设计,就可以实现规模化处理。自动化不难,难的是规模化,处理1万条数据哪怕很老旧的笔记本都可以很快,但是要处理1000万,就必须仔细考虑。多线程、多进程、生产者-消费者模式就是经常需要考虑的技术。

3. 总体设计

3.1 业务流程

根据对知识图谱构建过程进行梳理,形成以下处理流程:


image.png

如图所示,从原始数据(包括文件和数据库)进行数据接入,非结构化内容数据经过NLU处理过程,形成NLU结果(如实体、关系、事件等),下一步可通过结构化处理,核心步骤是与知识本体进行映射,最后再转成统一的图结构化数据,最后在写入到系统里面。

这个流程基本上是目前GoIN系统的数据处理逻辑过程,最后进入的ES、SQLGraph、Postgis数据库是目前实际的存储选型,但理论上是可以更换的。

3.2 流程框架

将这个过程进一步抽象,形成下面这个处理框架:


image.png

图中每个方框都称为组件,简单说明一下各个组件:

  • source 数据源,指定了原始数据来源,比如URL、Websocket、数据库或文件
  • loader 数据加载,从数据源中读取数据,对接各类source
  • parser 非结构化数据(目前主要考虑文本)的解析处理,这样解析器可以替换,CoreNLU、GolaxyNLP、LTP、HanLP都可以
  • mapper 对数据进行转换,借鉴了map-reduce框架的mapper步骤。理想的情况是支持一个或多个mapper串联,甚至可以进行分叉、合并
  • writer 输出组件,对接各种target
  • target 输出目标,包括文件、数据库等
    这些组件根据输入输出情况可分为3类:
  1. 源组件:主要是loader,提供数据源,本身不需要前置的输入组件
  2. 终端组件:包括writer和没有输出的mapper,即作为流程终点的组件
  3. 其他组件:需要其他组件作为输入并产生输出的组件,例如parser和普通的mapper

3.3 输入设计(需求相关)

为了满足常见业务需求,source支持文件和数据库,其中文件包括txt、word(doc/docx)、csv、excel(xls/xlsx)、pdf、json等,数据库支持MySQL、MongoDB、PostgreSQL等。未来可进一步扩展。

为了便于处理,将输入数据分为结构化和非结构化。非结构化数据是包含至少一个文本(str类型)字段的结构,前述所有类型的文件和数据库均可作为非结构化数据看待,但是对txt、word、pdf进行特殊处理,具体处理逻辑是:文件正文中的所有文字内容作为content字段;尝试从word、pdf的元数据提取标题title;如果标题未抽取或为空,可根据配置把文件名作为标题字段。

另一方面,所有数据也都可以作为结构化数据看待,根据mapper逻辑进行转换即可。

需要注意的是,excel文件包含多个sheet,每个sheet是一张二维表。Excel在日常业务中会有各种使用方式,例如表头不在第一行、单元格合并、包含了图表图像、单元格有样式及批注等,可以承载复杂的信息,但是这些使用方式虽然方便人们浏览查看,自动化处理却比较困难。这里只处理了简单情况,即只提取表格文字内容,表格第一行为表头。

3.4 流程配置设计(需求相关)

提供一种JSON格式的配置,用于对整个流程以及每个环节的参数进行配置。

框架程序对配置进行解析,构建一个流程实例,实例化每个流程组件,然后启动整个流程。

4. 详细设计

4.1 数据结构

以JSON对象(python中的dict)作为在流程中的数据结构,不仅可以保持整体结构一致,而且可以方便中间过程数据访问、增加字段信息。

输入中的一行记录(json/csv/excel/数据库等)或一个文本文件(word/pdf/txt等)对应一个JSON对象,整个处理的过程就是一个一个对象在处理管道中流动。这个对象既可以是业务数据(比如一篇新闻报道),也可以是业务元数据(比如一个任务,指示要删除的数据库记录),对于这两者,框架的处理没有本质区别,不同在于实际的业务逻辑。

通过增加特殊字段,记录流程元信息。例如对于一般文件word/pdf/txt,除了标题和正文字段,还可以获取原文件路径、文件owner、文件修改时间等;对于excel、数据库记录,除了单元格数据外,还可以获取其所在的表(table或sheet)、数据库(文件或database)、数据库地址(包括主机名和端口、协议等)。通过加入元信息,为后续处理流程提供更多信息,但也有问题,会导致有效载荷率降低,影响处理效率。可以通过配置进行控制。

4.2 配置文件设计

与流程组件对应,设计了对应的流程配置文件,其中每个组件的配置项与组件的业务逻辑相匹配,需要由组件设计者提供相应信息。其中start明确了流程的输入源,如果process中没有loader,则需要start明确对应的源(一般就是一个消息队列)作为输入。process是指明了需要运行的流程,它们自动进行串联,例如["loader", "parser"] 是把loader组件的结果输出给parser组件。loader组件较为特殊,一般只能作为第一个。process中的每个组件通过对应的配置项进行具体配置。

配置文件示例如下:

{
    "start": null,
    "process": [
        "loader",
        "parser",
        "mapper",
        "writer"
    ],
    "loader": {
        "source": {
            "type": "file",
            "file": {
                "path": "E:\\台湾凤梨非机构化数据\\data1",
                "allow": true,
                "include": [],
                "exclude": []
            },
            "db": {
                "type": "mysql",
                "table": []
            }
        },
        "name_as_title": true,
        "add_id": true,
        "keep_all": true,
        "select": [],
        "content": [
            "cont",
            "content"
        ]
    },
    "parser": {
        "module_name": "my_parser",
        "batch": 200,
        "name": "corenlu",
        "service": "http://117.160.193.19:9080/nlu_inte",
        "timeout": 180,
        "tasks": [
            "ner",
            "nel",
            "event",
            "topic",
            "sentiment"
        ],
        "lang": "zh",
        "parse_fields": [
            "content"
        ],
        "max_docs": 20,
        "max_length": 1500,
        "parallels": 10,
        "repeat_count": 3
    },
    "mapper": {
        "pipes": [
            "graph",
            "geo",
            "goin_graph",
            "rm_docfields"
        ],
        "graph": {
            "parser_field": "content",
            "keep_parse_field": false,
            "entity_linking": true,
            "shift_fields": [
                "topic",
                "sentiment",
                "keywords"
            ]
        },
        "geo": {
        },
        "rm_docfields": {
            "nlu_fields": true,
            "fields": []
        }
    },
    "writer": {
        "targets": [
            "kg_instance"
        ],
        "file": {
            "format": "jsonl",
            "file_path": "/Users/chenbo/data/testout.jsonl",
            "open_mode": "append"
        },
        "kg_instance": {
            "service": "http://127.0.0.1:8881/goin_instance/v1.0/graphs/_publish",
            "user_id": "42a4b1fa295206ed27df6720e68330a1",
            "dataset_id": 5273,
            "dataset_name": "国内外新闻报道"
        },
        "mongo": {
            "host": "127.0.0.1",
            "port": "27017",
            "user": "admin",
            "password": "123456",
            "db": "goin_data1",
            "table": "table1"
        }
    }
}

4.3 动态加载

为了实现处理环节的可配置、可扩展,采用元编程技术,实现根据组件名称加载对应的模块(或类)。

在框架代码方面,设计统一的包结构,实现模块按需加载。根据前文序数的环节名称,分别设计loader、parser、mapper、writer几个包,里面包含相应的模块文件,例如在loader下面包含load_csv.py、load_xls.py、load_doc.py、load_mysql.py等模块文件。通过内置函数import即可加载相应的模块。示例代码如下:

class ModuleManager:
    def __init__(self):
        self.modules = {}

    def load(self, prefix, name):
        mod_name = f"{prefix}.{name}"
        if mod_name not in self.modules:
            try:
                mod = __import__(mod_name, fromlist=(mod_name,))
            except Exception as e:
                print(mod_name, "not found")
                return None
            self.modules[mod_name] = mod

        return self.modules[mod_name].load

具体来说,是约定每个模块提供一个load函数,ModuleManager根据提供的包前缀和名称,加载相应的模块,返回其中的load函数。

这里可进一步改进:采用完全面向对象的方式进行实现,更加灵活、高效。另外可以考虑启动时加载和懒加载两种机制,各有优劣。

4.4 流程串联

为了实现流程可配置、可串联,需要能够将配置中的前一个组件的输出作为后一个组件的输入。考虑了两种实现方式:

  1. 简单实现方式,通过循环,对于每个输入对象,依次执行配置的流程组件。每个组件执行完,其输出作为当前对象值,如果对象为None/null,即结束此次输入对象的处理。
  2. 基于生产者-消费者模式的输入,各个组件从自己配置的输入队列(默认配置就是前一个组件的名称)中获取输入数据,处理完成后输出到配置的输出队列(默认配置为自己的名称),队列通过消息中间件或消息中心进行管理,从而将前后组件之间解耦。为了调试方便,框架提供一种本地的跨进程通信的消息队列,同时提供mongo和kafka的消息队列。

流程初始化代码如下:

def start_process(mq_server, task_config):
    """
    启动处理流程
    :param mq_server 消息队列
    :param task_config 任务配置
    :return:
    """
    process_list = task_config["process"]
    p_list = []
    # 开始的队列,支持从非loader环节开始
    _input = task_config.get("start")
    # 启动处理流程
    for process_item in process_list:
        _output = process_item
        config = task_config.get(process_item, {})
        config["_task"] = task_config
        pipe = Pipeline(process_item, config, _input, _output)
        p = Process(target=pipe.run, args=(mq_server,))
        p.start()
        p_list.append(p)
        _input = _output

    return p_list

这里采用python多进程启动每个组件,从而可以并行处理。组件从自己的_input队列中获取输入,产生的输出数据输出到_output队列中。

对于loader组件,由于是从配置的source中加载数据,并不需要一个_input队列,这种情况配置start为null即可。

4.5 组件处理

每个组件实例,定义了自己的输入_input和输出_output,如果_input不为空,则从_input中获取数据,否则就自己“创造”数据,比如loader是从配置的源中接入数据,相当于是自己“创造”了数据;处理结束后,如果输出不为空且_output也不为空,就输出到_output中,否则就忽略继续下一次循环。

5. 其他细节

  1. 对于loader,如果数据源是文件,一般是遍历每个文件(文件夹及子文件夹)后,整个流程结束。按前面的设计,如果不做相应处理的话,后续组件一直会空转。

为了处理这个问题,设计一个特殊消息{"_id": "__stop__"},每个组件遇到这个组件的时候,就往输出队列中输出同样的消息,然后自己结束退出。loader组件在扫描完文件或读取完数据库后就发送这样一条消息(也可配置一直监控文件夹或数据库),后续所有组件就会自己结束。

  1. 每个组件的配置参数中加入整体配置信息config["_task"] = task_config,方便每个组件获取整体流程配置。例如在mapper组件中,需要知道前面的parser组件是对哪个字段进行了NLU处理,就可以通过_task,进而获取parser的配置。

  2. 采用Python多进程实现组件,本地调试用的消息队列采用multiprocessing包中的Manager提供的数据结构,才能支持跨进程数据共享。如果使用多线程,就可以直接使用常用的数据结构(listdict

  3. 消息队列也采用了动态加载的可扩展设计,每种队列提供readwritedelete三个方法。简单流程、小规模数据或本地调试,可采用本地消息队列 mq_local,数据不会持久化存储。提供了mq_mongo,基于mongodb数据库的简单队列,数据持久保存。后续可提供其他队列,如kafka的支持。注意,read方法不会删除数据,只是获取数据,这样可以在处理失败后稍后重试,处理成功需要显式调用delete,对应kafka的提交偏移量的操作。

  4. 考虑到实际处理效率,消息队列read操作支持一次获取多余一条数据。这样主要是为了提高IO的效率,比如使用mongodb,一次读取一条开销相对比较大,因为可能处理一条会特别快。所以read支持可选的参数limit,默认为1,各个组件可以配置。

  5. loader如何避免重复接入数据?目前先考虑文件情况,通过配置备份文件夹,将读取过的文件都移动到备份文件夹,这样,如果数据量比较大,程序处理中断了,下次再继续处理其他文件,不用重复处理之前已经处理的每一个文件。但是对于多行文件,只处理了部分的情况怎么办?这个问题一般的处理框架和处理引擎都没法解决,得增加外部可靠的KV数据库(例如redis)进行记录。

  6. 组件执行报错怎么办?出错后的处理有几种情况:(1)处理偶尔出错或不可用,重试即可,立即重试或稍后重试 (2)组件有BUG或数据本身有问题,那么无论怎么重试都是错的。总体来说错误处理还是要依赖具体的业务组件,框架的设计支持重试几次后如果仍然失败,则加入到错误队列中,交给人工处理。

  7. CLI参数设计。为了方便使用,提供了一些CLI参数,能够实现对文件数据源的快速加载并输出到指定的文件中。

6. 灵魂拷问:为什么要重复造轮子

对于这个问题,必须说,应该在设计之前就必须要考虑。

大部分软件设计开发工作中,大部分甚至每一个模块、每一个函数、每一行代码,可能都是重复的,是有很多基础工作的。但是往往又不是完全都符合要求,有的是细节的差异,比如界面上的提示文字,有的是逻辑处理的差异,有的则是面对完全不同的应用场景,尽管表面上看起来可以复用。

对于设计人员来说,真正地艺术正在于此,找到符合多方面约束条件的设计方案。客观地说,这应该是工程,不是艺术。

首先,有没有现成的差不多满足需求的方案呢?ETL、tika、kafka、poi以及各种NLP库等,工具很多,但是没有能够进行知识图谱构建的。知识图谱目前开源的完整系统几乎没有。

第二,处理框架选择上为什么没有选择大数据的框架,比如Map-Reduce、Spark Streaming或Flink等?GoIN系统确实缺少一个强有力的大数据处理的后台,但是这样最主要的问题是把GoIN系统变得更加复杂。GoIN目前已经很复杂了,内部使用了7种数据库!!!以及一堆服务、凌乱不规整的库表结构和业务逻辑设计,可以说,简化系统的需求甚至比功能需求更迫切。通过这个框架设计,很大程度上将原有的数据接入、离线数据入库、案例数据处理等工作简单化、清晰化、弹性化、可配置化。

第三,开发层面、代码层面是否可以充分利用已有基础?这个确实是有的,但基本是一些基础代码的拷贝,例如多种数据源的访问读取、多进程、CoreNLU结果解析、入库等。

7. 下一步工作

目前基于这个框架基本实现了GoIN系统的非结构化数据解析入库流程。下一步工作包括:

  1. 对结构化数据,支持用户配置转换规则(已有基础代码),实现快速转换,未来处理一般的案例数据,就会非常方便
  2. 提供web服务,能够为现有GoIN的线上的业务流程提供支持或替换
  3. GoIN比较常用的一类需求是针对社交媒体数据,社交媒体数据是包含结构化部分和非结构化部分,需要实现一套针对社交媒体数据的图谱投建的基本流程

你可能感兴趣的:(一个简单可扩展的python数据处理框架)