这次的需求是解析一个用表格布局的word的文件内的信息;word模板采用了两层表格的样式,因此采用python-docx包来进行解析,打算先把表格的内容按行列先读取出来。最早版本的代码如下:


def table_nested_parsing(cell, tb, cells): try: cell_table = tb.cell(cell[0], cell[1]) _table_list = [] if len(cell_table.tables) > 0: block = cell_table.tables[0] for row in block.rows: # 读每行 row_content = [] for cell in row.cells: # 读一行中的所有单元格 c = cell.text row_content.append(c) _table_list.append(row_content) return _table_list except Exception as e: print(e) return '-1' def doc_parsing(doc): doc_list = [] for doc_part in doc.tables: tb1 = doc_part # 单线程模式 for row in range(len(tb1.rows)): for col in range(len(tb1.columns)): tb = table_nested_parsing((row, col), tb1, cells) if tb == '-1': return result if tb is None: continue if tb not in result: result.append(tb) if __name__ == '__main__': docx = Document('./test.docx') result = doc_parsing(docx) print(result)

可以看到doc_parsing中对最外层的表格进行遍历,并且将每个单元格作为一个表格对象,调用table_nested_parsing来解析其中的数据,并将行列的数据返回;由于python-docx在处理拆分合并的表格时,会按最多列的情况来解析导致数据重复,因此在添加表格数据到result之前做了去重

这段代码可以成功的按需求解析出表格中的数据,但有一个问题,解析一份文档需要耗时 Total time: 225.461 s 。对于需要批量处理的情况下,效率太低,于是开始着手优化。

对主要的处理函数doc_parsing函数打上@profile注释,并使用kernprof运行

> kernprof -l -v ./test.py
......此处忽略输出
Total time: 227.168 s
Function: doc_parsing at line 34

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
    34                                           @profile
    35                                           def doc_parsing(doc):
    36         1          7.0      7.0      0.0      doc_list = []
    37         2        198.0     99.0      0.0      for doc_part in doc.tables:
    38         2          3.0      1.5      0.0          tb1 = doc_part
    39                                                   # 单线程模式
    40       121        339.0      2.8      0.0          for row in range(len(tb1.rows)):
    41      2967       6814.0      2.3      0.0              for col in range(len(tb1.columns)):
    42      2848  227154137.0  79759.2    100.0                  tb = table_nested_parsing((row, col), tb1)
    43      2848       2147.0      0.8      0.0                  if tb == '-1':
    44         1          1.0      1.0      0.0                      return result
    45      2847       1251.0      0.4      0.0                  if tb is None:
    46      2504       1010.0      0.4      0.0                      continue
    47       343       1863.0      5.4      0.0                  if tb not in result:
    48        33         22.0      0.7      0.0                      result.append(tb)

可以看到table_nested_parsing方法占用了几乎全部的运行时间,而且这个方法调用了2848次,经过和实际的word比对,发现第一层表格完全没有那么多的单元格,于是第一个思路就是降低无效的table_nested_parsing方法调用。

于是,在下方处理结果数据的地方,打印出存在有效数据的行和列数,发现当行数>=1时,所有有效数据都存在第14列上 ,于是加上对应的剪枝语句,再次运行

Total time: 14.5656 s
Function: doc_parsing at line 34

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
    34                                           @profile
    35                                           def doc_parsing(doc):
    36         1          6.0      6.0      0.0      doc_list = []
    37         2        125.0     62.5      0.0      for doc_part in doc.tables:
    38         2          1.0      0.5      0.0          tb1 = doc_part
    39                                                   # 单线程模式
    40       121        232.0      1.9      0.0          for row in range(len(tb1.rows)):
    41      2967       6806.0      2.3      0.0              for col in range(len(tb1.columns)):
    42      2848       1234.0      0.4      0.0                  if row > 1 and col is not 14:  # 去除无效访问
    43      2682        937.0      0.3      0.0                      continue
    44       166   14555643.0  87684.6     99.9                  tb = table_nested_parsing((row, col), tb1)
    45       166        288.0      1.7      0.0                  if tb == '-1':
    46         1          1.0      1.0      0.0                      return result
    47       165         82.0      0.5      0.0                  if tb is None:
    48       112         48.0      0.4      0.0                      continue
    49        53        203.0      3.8      0.0                  if tb not in result:
    50        33         22.0      0.7      0.0                      result.append(tb)

可以发现时间已经由原来的227.168 s缩短到14.5656 s,效率提升了16倍;同时table_nested_parsing方法的调用次数也减少到了166次,基本符合实际数据的结构。但是99%的时间消耗还是在table_nested_parsing方法上。

对子单元格的处理函数table_nested_parsing函数打上@profile注释,并使用kernprof运行

Total time: 14.2929 s
Function: table_nested_parsing at line 16

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
    16                                           @profile
    17                                           def table_nested_parsing(cell, tb):
    18       166        108.0      0.7      0.0      try:
    19       166   13428371.0  80893.8     94.0          cell_table = tb.cell(cell[0], cell[1])
    20       165        317.0      1.9      0.0          _table_list = []
    21       165       6587.0     39.9      0.0          if len(cell_table.tables) > 0:
    22        53        996.0     18.8      0.0              block = cell_table.tables[0]
    23       316       2809.0      8.9      0.0              for row in block.rows:   # 读每行
    24       263        128.0      0.5      0.0                  row_content = []
    25      4676     669253.0    143.1      4.7                  for cell in row.cells:  # 读一行中的所有单元格
    26      4413     180856.0     41.0      1.3                      c = cell.text
    27      4413       3193.0      0.7      0.0                      row_content.append(c)
    28       263        145.0      0.6      0.0                  _table_list.append(row_content)
    29        53         25.0      0.5      0.0              return _table_list
    30         1          2.0      2.0      0.0      except Exception as e:
    31         1         65.0     65.0      0.0          print(e)
    32         1          1.0      1.0      0.0          return '-1'

发现94%的时间花在了Table对象获取单元格的过程,于是去看cell()方法的实现

def cell(self, row_idx, col_idx):
    """
    Return |_Cell| instance correponding to table cell at *row_idx*,
    *col_idx* intersection, where (0, 0) is the top, left-most cell.
    """
    cell_idx = col_idx + (row_idx * self._column_count)
    return self._cells[cell_idx]

看起来貌似只是一个简单的从数组中取指定值的操作,应该不需要那么长的时间,于是继续查看self._cells这个数组的处理方式,发现这是个用@property装饰器的方法

def _cells(self):
    """
    A sequence of |_Cell| objects, one for each cell of the layout grid.
    If the table contains a span, one or more |_Cell| object references
    are repeated.
    """
    col_count = self._column_count
    cells = []
    for tc in self._tbl.iter_tcs():
        for grid_span_idx in range(tc.grid_span):
            if tc.vMerge == ST_Merge.CONTINUE:
                cells.append(cells[-col_count])
            elif grid_span_idx > 0:
                cells.append(cells[-1])
            else:
                cells.append(_Cell(tc, self))
    return cells

原来每次调用cell()都会导致这个_cells()重算一遍,于是考虑把_cells()的结果缓存下来,避免每次调用都重取

def doc_parsing(doc):
    ...
    cells = tb1._cells # 缓存这张表格的单元格列表
    ...

def table_nested_parsing(cell, tb, cell): # 将缓存的列表传入
    # cell_table = tb.cell(cell[0], cell[1])
    cell_idx = cell[1] + (cell[0] * tb._column_count) # 使用原.cell()方法的方式计算idx
    cell_table = cells[cell_idx] # 直接取

再运行测试

Total time: 0.877686 s
Function: table_nested_parsing at line 16

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
    16                                           @profile
    17                                           def table_nested_parsing(cell, tb, cells):
    18       166        109.0      0.7      0.0      try:
    19                                                   # cell_table = tb.cell(cell[0], cell[1])
    20       166       5482.0     33.0      0.6          cell_idx = cell[1] + (cell[0] * tb._column_count)
    21       166        148.0      0.9      0.0          cell_table = cells[cell_idx]
    22       165         90.0      0.5      0.0          _table_list = []
    23       165       3357.0     20.3      0.4          if len(cell_table.tables) > 0:
    24        53        919.0     17.3      0.1              block = cell_table.tables[0]
    25       316       2244.0      7.1      0.3              for row in block.rows:   # 读每行
    26       263        126.0      0.5      0.0                  row_content = []
    27      4676     686406.0    146.8     78.2                  for cell in row.cells:  # 读一行中的所有单元格
    28      4413     175236.0     39.7     20.0                      c = cell.text
    29      4413       3317.0      0.8      0.4                      row_content.append(c)
    30       263        172.0      0.7      0.0                  _table_list.append(row_content)
    31        53         26.0      0.5      0.0              return _table_list
    32         1          1.0      1.0      0.0      except Exception as e:
    33         1         51.0     51.0      0.0          print(e)
    34         1          2.0      2.0      0.0          return '-1'

效果立竿见影,总的运行时间缩短到了0.87s,已经基本能满足日常使用了。综合下来,剪枝+缓存让这个脚本的效率提高了约256倍。