ClueWeb09 数据集把玩日记(下)
这篇主要包括怎么抽取一个网页的内容,和怎么发现瓶颈并优化性能的。代码都放在 GitHub 上面。有的正则表达式和代码逻辑直接看代码可能更清楚一点。前一篇在此:ClueWeb09 数据集把玩日记(上)
网页抽取
通过前面拿到了 ClueWeb09 的数据集,可以挨个读出 HTML 数据进行处理了。
这次的目标是把 FACC1 中标注的实体取出来,找到对应 HTML 数据中所在句子。由于直接用给定的字节位置难以确定前后的句子边界,同时一个文档中的同一个实体基本上多次出现时都是一样的,因此决定先提取句子,再找到标注实体出现在哪些句子当中。
首先涉及到提取 HTML 中句子的工作。Python 下直接用 bs4.BeautifulSoup(下面使用 bs 表示)就可以处理。然而踩到了不少坑:
- bs.get_text() 提取出的内容可能是多个句子的段落,也可能是一个句子的多个碎片。
- bs.get_text() 默认把子标签的文字提取出来不加分隔符。
- 迭代获取 bs.descendants 会得到子标签列表,把所有子标签都作为列表元素。从而如果叶子上有一个句子,则这个句子在多个列表元素上调用 get_text() 时都会被重复提取,甚至可能一个句子的多个碎片重复出现,无法排重。
唯一的办法就是直接使用 bs.get_text,不进行节点遍历。经过多次测试,总结出一些原则:
- 如果 html 内容以 HTTP/1.1 200 OK 等头信息开头,全部去掉。
- 去掉 html 中 <script [type=”text/javascript”]> … </script> 和 <style> … </style>标签的内容
- 去掉所有的注释 <!– … –>
- 去掉所有长度为0的句子
- 把 u’\xa0’ 换成普通空格,这是在 bs 解析时把 html 中的
& nbsp;
等 HTML entities 进行替换所转换得到的空格,在 ASCII Table 中有规定 - 因为 HTML 不会把 <br> 以外的换行解释为换行,故把连续出现的多个 \n, \t, \r, 空格等等替换成单个空格。
在经过这些处理之后,把提取出的段落用 \n 分割,可以用 python requests wrapper 交给 CoreNLP Server 去处理。这部分使用了大量正则表达式进行处理,如果需要可以参考一下 re 模块的说明,例如 .*? 加了问号可以表示non-greedy 模式的 match、(?is) 开头可以空匹配但是指定整个表达式的匹配约定等等。
def preprocess(html_text):
# discard html headers
html_text = re.sub('^HTTP[^<]*<', '<', html_text, flags=re.DOTALL)
# discard possible comments in raw html
html_text = re.sub(r'<!--(.*?)-->', ' ', html_text, flags=re.DOTALL)
# Since new line feeds in HTML have no use,
# remove all new lines in the original text before we do,
# because we will use the line feed as separator for differenct sentences.
html_text = re.sub(r'[\r\n]+', ' ', html_text)
html_text = re.sub(r' +', ' ', html_text)
return html_text
def get_sentences_from_html(html, nlp=None):
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
# discard script and css style tag
for tag in soup(['script', 'style']):
tag.extract()
# extract text from all tags.
text = soup.get_text(separator=u'\n')
paras = text.splitlines()
# substitute all non-breaking space to normal space
paras = (re.sub(u'\xa0', ' ', p) for p in paras)
# discard comment
paras = (p for p in paras if not (re.match(r'^[\r\n\t ]*<!--', p)))
paras = (re.sub(r'<!--[^>]+-->', u' ', p) for p in paras)
# substitute continuous blanks
paras = (re.sub(r'\t+', u'\t', p) for p in paras)
paras = (re.sub(r'[\r\n]+', u'\n', p) for p in paras)
paras = (re.sub(r' +', u' ', p) for p in paras)
# discard empty string
paras = (p.strip() for p in paras if len(p.strip()) > 0)
# CoreNLP wrapper 使用 requests 库,传输 POST 数据时默认用 ASCII decode,需要显式使用 utf-8
sentences = list(s for s in nlp_analyze(u"\n".join(p for p in paras).encode('utf-8'), nlp))
return sentences
最后直接输出就好了。
性能调优
这里才是感觉最想分享的经验。前一篇说过了这个数据集非常大。于是花了一点时间用土办法做了并行化。跑起来定时用 wc -l * 看了一眼输出结果,计算了一下平均每秒才 44 条。这太慢了!
感慨了一番,工程师必须具备的能力除了实现功能之外,必须还有写测试、做性能 profiling。
于是研究了一下。Python 标准库里带了几个 profiling 工具,其中推荐使用 cProfile。
用起来非常简单:
$ python -m cProfile facc1.py FACC1/profile.ids.tmp /media/ClueWeb09/ClueWeb09_English_1/en0000/00.warc.gz 1> dump/all.out 2>dump/all.err
它把内容输出到 stdout,因此可能会和程序本身输出相混。我使用 -o profile.out 参数时输出的为乱码没有继续尝试。
53951591 function calls (53948462 primitive calls) in 16.211 seconds
Ordered by: standard name
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.000 0.000 0.000 0.000 <string>:1(<module>)
1 0.000 0.000 0.000 0.000 <string>:1(ArgInfo)
1 0.000 0.000 0.000 0.000 <string>:1(ArgSpec)
1 0.000 0.000 0.000 0.000 <string>:1(Arguments)
1 0.000 0.000 0.000 0.000 <string>:1(Attribute)
............
92 0.000 0.000 0.015 0.000 _lxml.py:53(parser_for)
59 0.000 0.000 0.000 0.000 _lxml.py:62(__init__)
97308 0.014 0.000 0.014 0.000 _lxml.py:72(_getNsTag)
151 0.000 0.000 14.140 0.094 _lxml.py:80(prepare_markup)
1 0.020 0.020 0.034 0.034 lxml.etree.pyx:1(PyMODINIT_FUNC PyInit_etree(void))
37363 0.002 0.000 0.002 0.000 lxml.etree.pyx:123(__len__)
............
所有的子调用都会打出来,但对 lxml 这种 C 库只会跟踪到 wrapper 部分。各列分别含义是:总调用次数、不包括调用子函数的总时间、单个调用耗时、包括子调用的总时间、单个调用耗时、所属函数。
在 stackoverflow 的一个问题下有人安利自己开发的可以把 cProfile 输出图形化的工具,需要安装 graphviz,但使用后可以看到一个 png 图片,方便直观可以尝试:
$ sudo apt-get install graphviz # ubuntu 下安装 graphviz 相关的包
$ pycallgraph graphviz -- ./mypythonscript.py
这里可以分析得到,bs4 中使用 lxml 是最耗时(60%)的部分(对 CoreNLP 的网络请求都没有那么多),因此这里是改进以后收获最大的部分。
重新回头看我们的程序,抽取网页并没有对 HTML 的节点进行操作,因此 parse 是没有什么作用的。考虑人工替换掉。参考之前的整理经验,实际上大部分句子并不是 HTML 中的换行得到的,而是交给了 CoreNLP 做解析。因此实际上是不是直接把 HTML 标签去掉就可以了呢?
搜索 Python 的 NLP 工具包实现 NLTK,曾经有过 clean_html 的抽取函数,但后来也转向 bs4 实现了。
def clean_html(html):
- """
- Remove HTML markup from the given string.
-
- :param html: the HTML string to be cleaned
- :type html: str
- :rtype: str
- """
-
- # First we remove inline JavaScript/CSS:
- cleaned = re.sub(r"(?is)<(script|style).*?>.*?(</\1>)", "", html.strip())
- # Then we remove html comments. This has to be done before removing regular
- # tags since comments can contain '>' characters.
- cleaned = re.sub(r"(?s)<!--(.*?)-->[\n]?", "", cleaned)
- # Next we can remove the remaining tags:
- cleaned = re.sub(r"(?s)<.*?>", " ", cleaned)
- # Finally, we deal with whitespace
- cleaned = re.sub(r" ", " ", cleaned)
- cleaned = re.sub(r" ", " ", cleaned)
- cleaned = re.sub(r" ", " ", cleaned)
- return cleaned.strip()
+ raise NotImplementedError ("To remove HTML markup, use BeautifulSoup's get_text() function")
def clean_url(url):
- html = compat.urlopen(url).read()
- return clean_html(html)
+ raise NotImplementedError ("To remove HTML markup, use BeautifulSoup's get_text() function")
发现它使用的正则和我们之前的操作也差不多。于是新实现了一个 get_sentences_from_html_v2() 函数 在之前 dump 出来的 html 文件样本上实验了一番,肉眼观察输出的句子效果很好,与 bs4 的功能差不多!
这下跑了一番,在一个 FACC1 文件的 500 个抽样上跑,原本需要 21 秒的程序,直接降到了 7.5 秒!巨大的提升,并且与 profile 的结论相近。
但是问题还没完,为什么 NLTK 会选择转向 bs4 呢?自然,HTML 本身并不是一个可以用正则语法囊括的,用正则表达式解析的做法本身就是走偏了,只能在很小的场景下使用。对应的 badcase 马上就被发现了:
<div onclick="if (a > 100) foo();">
.... blablabla ...
</div>
面对这样的标签,在 onclick 事件代码中的 > 符号就引起了歧义,从而 100) foo();”> 这部分也被当成了句子。
解决办法就是用科学的方法解析 HTML tag,好在这里只需处理 tag,那就要想到自动机了。
首先花一天写了一个正常的自动机,支持状态之间的接受输入字符的转换、无输入字符的转换、没有合适输入时的默认转换。详细代码见 naive_fsa.py,此处不再叙述。同时需要写一个 HTML 的 tag 的自动机转换规则,主要有两种:
<tagname attr="attrvalue"></tagname>
<tagname attr='attrvalue'></tagname>
因此写下 HTML Tag 自动机所需要的规则数据结构,详细可以看 clean_html.py:
HTML_TAG_STATES = {
"start": {
"trans": [ ('<', 'tag_name') ]
},
"tag_name": {
"trans": [ (' ', 'waiting_for_attr'), ('>', 'end') ],
"default": "tag_name"
},
"waiting_for_attr": {
"trans": [('>', 'end')],
"default": "attr_name"
},
"attr_name": {
"trans": [('=', 'attr_value_start'), ('>', 'end')],
"default": "attr_name"
},
"attr_value_start": {
"trans": [('"', 'attr_value_double_quote'), ('>', 'end')],
"default": "attr_value_start"
},
"attr_value_double_quote": {
"trans": [('"', 'end_of_attr_value')],
"default": "attr_value_double_quote"
},
"attr_value_single_quote": {
"trans": [("'", 'end_of_attr_value')],
"default": "attr_value_single_quote"
},
"end_of_attr_value": {
"direct": "waiting_for_attr"
},
"end": {}
}
至此,做完了 clean_html.py 以后整个解析程序完成。最后发现样本集上耗时 9.1 秒,相比直接正则多花了 2 秒左右,符合常理。
同时,由于我们已经摆脱了 bs4 和 lxml 的依赖,已经完全转到 pure python 上面来,可以直接改用 pypy 执行进行优化,最终使用 pypy 跑,耗时为 8.4 秒左右。
后续优化
实际上即使到最后每秒也只有 300 个左右的结果输出,还是太慢(全部处理需要133天且保证电脑不意外关机)…. 又要想别的办法了。
- 自动机的实现中,每次后移一位用暴力搜索,能否参考 KMP 的思想移动迅速一点?
- 健壮性:脚本多进程执行时依然出现了子进程挂掉的情况,在长时间、多进程的环境下这种情况是常态,必须考虑重新设计脚本接口,支持记录当前处理状态,并能死掉后重启。
- 日志的记录需要完备,避免过分依赖 stdout,同时也就可以方便实现监控了。这块功能可以形成一个框架性质的代码,方便将来的所有脚本进行重用。
- 实验 html 提取规则时很多拍脑袋的工作,能否更高效地优化规则提出的流程,是否有简单工具?
本文使用署名协议进行授权.