使用Python生成LaTeX 数学公式

在阅读算法文献或者数学相关的文章中经常会看到一些简单或复杂的数学公式,最近在分享此类文章时,想使用LaTex键入数学公式以美化阅读,发现需要反复去查询LaTex相关的语法,效率较低且容易出错。

最近 GitHub 上出现了一个开源项目 latexify_py,它使用 Python 就能生成 LaTeX 数学公式。打开Google Colaboratory示例列举了几个案例:

使用Python生成LaTeX 数学公式 - 图1

先试试看

在本地安装相应的Python包,Python版本 >= 3.6

  1. pip install latexify-py

参考官方示例进行测试:

  1. import math
  2. import latexify
  3. @latexify.with_latex
  4. def solve(a, b, c):
  5. return (-b + math.sqrt(b ** 2 - 4 * a * c)) / (2 * a)
  6. if __name__ == '__main__':
  7. print(solve)

终端打印结果为:

  1. \mathrm{solve}(a, b, c)\triangleq \frac{-b + \sqrt{b^{2} - 4ac}}{2a}

将打印结果输入到支持LaTeX的编辑器中,以Typora为例。选择插入公式块:

使用Python生成LaTeX 数学公式 - 图2%5Ctriangleq%20%5Cfrac%7B-b%20%2B%20%5Csqrt%7Bb%5E%7B2%7D%20-%204ac%7D%7D%7B2a%7D%0A#card=math&code=%5Cmathrm%7Bsolve%7D%28a%2C%20b%2C%20c%29%5Ctriangleq%20%5Cfrac%7B-b%20%2B%20%5Csqrt%7Bb%5E%7B2%7D%20-%204ac%7D%7D%7B2a%7D%0A)

于是,把最近阅读的facebook开源的prophet时间序列预测算法提到的饱和增长模型公式进行测试,原文中为

使用Python生成LaTeX 数学公式 - 图3

开始在python中键入代码:

  1. @latexify.with_latex
  2. def g(t):
  3. return C(t) / (1 + exp(1-(k + alpha(t) ** T * delta) * (t -(m + alpha(t) ** T * gamma))))

终端打印结果并输入Typora为:

  1. \mathrm{g}(t)\triangleq \frac{\mathrm{C}\left(t\right)}{1 + \mathrm{exp}\left(1 - (k + \mathrm{{\alpha}}\left(t\right)^{t}{\delta})(t - m + \mathrm{{\alpha}}\left(t\right)^{T}{\gamma})\right)}

使用Python生成LaTeX 数学公式 - 图4%5Ctriangleq%20%5Cfrac%7B%5Cmathrm%7BC%7D%5Cleft(t%5Cright)%7D%7B1%20%2B%20%5Cmathrm%7Bexp%7D%5Cleft(1%20-%20(k%20%2B%20%5Cmathrm%7B%7B%5Calpha%7D%7D%5Cleft(t%5Cright)%5E%7BT%7D%7B%5Cdelta%7D)(t%20-%20m%20%2B%20%5Cmathrm%7B%7B%5Calpha%7D%7D%5Cleft(t%5Cright)%5E%7BT%7D%7B%5Cgamma%7D)%5Cright)%7D%0A#card=math&code=%5Cmathrm%7Bg%7D%28t%29%5Ctriangleq%20%5Cfrac%7B%5Cmathrm%7BC%7D%5Cleft%28t%5Cright%29%7D%7B1%20%2B%20%5Cmathrm%7Bexp%7D%5Cleft%281%20-%20%28k%20%2B%20%5Cmathrm%7B%7B%5Calpha%7D%7D%5Cleft%28t%5Cright%29%5E%7BT%7D%7B%5Cdelta%7D%29%28t%20-%20m%20%2B%20%5Cmathrm%7B%7B%5Calpha%7D%7D%5Cleft%28t%5Cright%29%5E%7BT%7D%7B%5Cgamma%7D%29%5Cright%29%7D%0A)

对比发现python输出的公式中有一个错误:删除了一个括号,而python代码中是包含的,由

使用Python生成LaTeX 数学公式 - 图5%5E%7BT%7D%7B%5Cgamma%7D)%0A#card=math&code=t%20-%20%28m%20%2B%20%5Cmathrm%7B%7B%5Calpha%7D%7D%5Cleft%28t%5Cright%29%5E%7BT%7D%7B%5Cgamma%7D%29%0A)

变成了:

使用Python生成LaTeX 数学公式 - 图6%5E%7BT%7D%7B%5Cgamma%7D%0A#card=math&code=t%20-%20m%20%2B%20%5Cmathrm%7B%7B%5Calpha%7D%7D%5Cleft%28t%5Cright%29%5E%7BT%7D%7B%5Cgamma%7D%0A)

为了进一步验证上面出现的问题,输入一段很简单的代码:

  1. @latexify.with_latex
  2. def test(a, b):
  3. return - (a + b)

输出的公式和预想的一致:

使用Python生成LaTeX 数学公式 - 图7%5Ctriangleq%20-%5Cleft(a%20%2B%20b%5Cright)%0A#card=math&code=%5Cmathrm%7Btest%7D%28a%2C%20b%29%5Ctriangleq%20-%5Cleft%28a%20%2B%20b%5Cright%29%0A)

这时,小小的修改一下代码:

  1. @latexify.with_latex
  2. def test(a, b):
  3. return 1 - (a + b)

预想的公式应该为:

使用Python生成LaTeX 数学公式 - 图8%5Ctriangleq%201%20-%20(a%20%2B%20b)%0A#card=math&code=%5Cmathrm%7Btest%7D%28a%2C%20b%29%5Ctriangleq%201%20-%20%28a%20%2B%20b%29%0A)

而实际却是:

使用Python生成LaTeX 数学公式 - 图9%5Ctriangleq%201%20-%20a%20%2B%20b%0A#card=math&code=%5Cmathrm%7Btest%7D%28a%2C%20b%29%5Ctriangleq%201%20-%20a%20%2B%20b%0A)

猜想,这可能是一个bug或者是输入的方式不对,虽然这个问题很好解决,但是一直很疑惑。。。。。

latexify_py做了什么?

为了一探究竟,尝试去阅读其源码,看看它都做了哪些事情?

首先入口是@latexify.with_latex这个注解。latexify提供with_latex和get_latex两个注解,with_latex只是先做一些初始化,实际也是调用get_latex。重点看一下get_latex,其源码:

  1. def get_latex(fn, math_symbol=True):
  2. try:
  3. source = inspect.getsource(fn)##获取整个模块的源代码
  4. except Exception:
  5. # Maybe running on console.
  6. source = dill.source.getsource(fn)
  7. return LatexifyVisitor(math_symbol=math_symbol).visit(ast.parse(source)) ##ast.parse把源码解析为AST节点,AST是抽象语法树,不依赖于具体的文法,不依赖于语言的细节,我们将源代码转化为AST后,可以对AST做很多的操作

LatexifyVisitor继承ast的NodeVisitor,ast.NodeVisitor是一个专门用来遍历语法树的工具,可以通过继承这个类来完成对语法树的遍历以及遍历过程中的处理。

LatexifyVisitor首先从根节点root进行遍历,在遍历的过程中,每个节点类型都有专用的类型处理函数,以”visit_” + “Node类型”为名称,如果不存在,则调用通用的的处理函数generic_visit。

在latexify的core.py直接引入astunparse,将生成的ast打印出来:

  1. def get_latex(fn, math_symbol=True):
  2. try:
  3. source = inspect.getsource(fn)
  4. print(astunparse.dump(ast.parse(source)))
  5. except Exception:
  6. # Maybe running on console.
  7. source = dill.source.getsource(fn)
  8. return LatexifyVisitor(math_symbol=math_symbol).visit(ast.parse(source))

下面是test对应的ast结构:

  1. Module(
  2. body=[FunctionDef(
  3. name='test',
  4. args=arguments(
  5. posonlyargs=[],
  6. args=[
  7. arg(
  8. arg='a',
  9. annotation=None,
  10. type_comment=None),
  11. arg(
  12. arg='b',
  13. annotation=None,
  14. type_comment=None)],
  15. vararg=None,
  16. kwonlyargs=[],
  17. kw_defaults=[],
  18. kwarg=None,
  19. defaults=[]),
  20. body=[Return(value=BinOp(
  21. left=Constant(
  22. value=1,
  23. kind=None),
  24. op=Sub(),
  25. right=BinOp(
  26. left=Name(
  27. id='a',
  28. ctx=Load()),
  29. op=Add(),
  30. right=Name(
  31. id='b',
  32. ctx=Load()))))],
  33. decorator_list=[Attribute(
  34. value=Name(
  35. id='latexify',
  36. ctx=Load()),
  37. attr='with_latex',
  38. ctx=Load())],
  39. returns=None,
  40. type_comment=None)],
  41. type_ignores=[])

首先访问根节点root,root为Moudle类型,会调用visit_Moudle函数,以此始遍历子节点FunctionDef、Return和BinOp,调用对应的visit_FunctionDef、visit_Return和vist_BinOp。

参照打印出来的python公式代码和ast结构,来分析一下整体逻辑:

vist_FunctionDef

  1. def visit_FunctionDef(self, node):
  2. name_str = r'\mathrm{' + str(node.name) + '}'
  3. arg_strs = [self._parse_math_symbols(str(arg.arg)) for arg in node.args.args]
  4. body_str = self.visit(node.body[0])
  5. return name_str + '(' + ', '.join(arg_strs) + r')\triangleq ' + body_str

遍历FunctionDef节点后,输出为:

  1. \mathrm{test}(ab)\triangleq

visit_Return

  1. def visit_Return(self, node):
  2. return self.visit(node.value)

Return节点的值为子节点,类型为BinOp。ast将输入的代码分为left和right,test例子中,left为常数1,right是下一个子节点,类型为BinOp,op为运算符,这里为Sub减法。看看visit_BinOp:

visit_BinOp

  1. def visit_BinOp(self, node):
  2. priority = {
  3. ast.Add: 10,
  4. ast.Sub: 10,
  5. ast.Mult: 20,
  6. ast.MatMult: 20,
  7. ast.Div: 20,
  8. ast.FloorDiv: 20,
  9. ast.Mod: 20,
  10. ast.Pow: 30,
  11. }
  12. def _unwrap(child):
  13. return self.visit(child)
  14. def _wrap(child):
  15. latex = _unwrap(child)
  16. if isinstance(child, ast.BinOp):
  17. cp = priority[type(child.op)] if type(child.op) in priority else 100
  18. pp = priority[type(node.op)] if type(node.op) in priority else 100
  19. if cp < pp:
  20. return '(' + latex + ')'
  21. return latex
  22. l = node.left
  23. r = node.right
  24. reprs = {
  25. ast.Add: (lambda: _wrap(l) + ' + ' + _wrap(r)),
  26. ast.Sub: (lambda: _wrap(l) + ' - ' + _wrap(r)),
  27. ast.Mult: (lambda: _wrap(l) + _wrap(r)),
  28. ast.MatMult: (lambda: _wrap(l) + _wrap(r)),
  29. ast.Div: (lambda: r'\frac{' + _unwrap(l) + '}{' + _unwrap(r) + '}'),
  30. ast.FloorDiv: (lambda: r'\left\lfloor\frac{' + _unwrap(l) + '}{' + _unwrap(r) + r'}\right\rfloor'),
  31. ast.Mod: (lambda: _wrap(l) + r' \bmod ' + _wrap(r)),
  32. ast.Pow: (lambda: _wrap(l) + '^{' + _unwrap(r) + '}'),
  33. }
  34. if type(node.op) in reprs:
  35. return reprs[type(node.op)]()
  36. else:
  37. return r'\mathrm{unknown\_binop}(' + _unwrap(l) + ', ' + _unwrap(r) + ')'

ast.Add和ast.Sub设置的优先级都为10,_wrap方法通过优先级来判断是否添加括号,即:

  1. cp = priority[type(child.op)] if type(child.op) in priority else 100
  2. pp = priority[type(node.op)] if type(node.op) in priority else 100
  3. if cp < pp:
  4. return '(' + latex + ')'

test例子中child.op为Sub,node.op是right中的op为Add,优先级相同不添加括号,所以输出:

  1. 1 - a + b

遍历结束后输出:

  1. \mathrm{test}(a, b)\triangleq 1 - a + b

这和公式实际上表达的意思南辕北辙,解决方法就是将小于改为小于等于,即

  1. if cp <= pp:
  2. return '(' + latex + ')'