把数学表达式转换成自然语言文本,通常出现在自动讲解、题库解析、无障碍阅读等场景。它看起来像简单的模板替换,实际上涉及表达式解析、运算优先级识别和中文语序调整。如果不先理解公式的内部结构,很容易在处理 x+2*y 和 (x+2)*y 这类表达式时读错层次。下面会介绍两种主流实现路线:一种是基于 SymPy 的快速方案,另一种是手写解析器的可控方案,并重点说明中文自然语言生成时需要注意的细节。

先理解问题:为什么需要表达式树
数学表达式的字符串形式并不适合直接做自然语言输出。以 x+2*y 为例,如果只对字符串做替换,很容易忽略运算符优先级,错误地把结果读成“x加2乘以y”。实际上这个公式的语义是“x加上2乘以y”,其中“2乘以y”是一个整体。只有把公式解析成树状结构,才能让后续生成逻辑知道哪些部分应该先组合。
表达式树中的根节点通常是优先级最低的运算。对于 x+2*y,根节点是加法,左子树是变量 x,右子树是 2*y。生成自然语言时从根节点开始递归遍历,遇到加法就输出“左子树描述加上右子树描述”,遇到乘法再进入下一层处理。这样得到的文本天然符合数学语义,不需要额外判断括号位置。
Python 里获取表达式树有两种常见方式。第一种是使用 SymPy 的 parse_expr 函数,它能解析字符串并返回符号表达式对象,其内部已经组织好了加法、乘法、幂等节点。第二种是自己写分词器和递归下降解析器,不依赖第三方库,适合对输出格式、错误提示有更细致要求的场景。下面的代码先展示 SymPy 的基本解析效果。
from sympy import symbols, sin, cos, parse_expr
x = symbols('x')
expr = parse_expr('sin(x) + x**2')
print(expr.func) # Add
print(expr.args) # (x**2, sin(x))
从输出可以看到,SymPy 把表达式识别为 Add 类型的对象,args 里是两个子表达式。不过这里有一个细节需要注意:SymPy 可能会按照内部规则对加法项和乘法因子进行重排,输出顺序不一定和用户输入一致。如果只是生成朗读文本,这种重排通常可以接受;如果必须严格保留原式顺序,就要考虑使用 evaluate=False 或自行解析。
理解了树结构之后,自然语言生成就变成了树遍历问题。每个节点类型对应一套中文模板:加法用“加上”连接,幂指数为 2 用“的平方”表示,正弦函数用“某值的正弦”表示。模板设计得越细致,最终读出来的句子越自然。
基于 SymPy 递归生成中文描述
SymPy 提供了丰富的表达式类型判断方法,例如 is_Number、is_Symbol、is_Add、is_Mul、is_Pow 等。我们可以利用这些方法编写递归函数,把表达式对象逐步转换成人话。下面是一个可运行的示例,它支持数字、变量、加减乘除、幂以及 sin、cos 函数。
from sympy import symbols, sin, cos, parse_expr
x, y = symbols('x y')
def describe(expr):
if expr.is_Number:
return str(expr)
if expr.is_Symbol:
return str(expr)
if expr.is_Add:
parts = [describe(arg) for arg in expr.args]
return ' 加上 '.join(parts)
if expr.is_Mul:
coeff = 1
rest = []
for arg in expr.args:
if arg.is_Number:
coeff = coeff * arg
else:
rest.append(describe(arg))
coeff_text = str(coeff) if coeff != 1 else ''
body = '乘上'.join(rest)
return coeff_text + body if coeff_text else body
if expr.is_Pow:
base, exp = expr.args
if exp == 2:
return describe(base) + '的平方'
if exp == 3:
return describe(base) + '的立方'
return describe(base) + '的' + describe(exp) + '次方'
if expr.func.__name__ == 'sin':
return describe(expr.args[0]) + '的正弦'
if expr.func.__name__ == 'cos':
return describe(expr.args[0]) + '的余弦'
return str(expr)
expr = parse_expr('sin(x) + 2*x**2 - y/3')
print(describe(expr))
这段代码先判断叶子节点,再处理复合表达式。加法部分直接递归处理所有子项并用“加上”连接。乘法部分把数字系数单独提取出来,避免出现“2乘以x的平方”这种生硬表述,而是尽量输出“2乘上x的平方”。如果系数是 1,则直接省略数字,只描述后面的因子。
这个版本的不足也很明显。SymPy 会把 y/3 转成 y*3**(-1),也就是 y 乘以 3 的负一次方,因此 describe 会输出“y乘上3的-1次方”,而不是人们习惯的“y除以3”。要解决这个问题,需要在乘法节点中进一步识别负数幂,并把这类结构转换为“除以”关系。类似的特殊情况还包括 sqrt、log 以及带括号的分子分母结构。
因此,SymPy 方案更适合快速原型以及表达式结构相对规范的场景。如果公式来自用户输入,可能出现不规范字符、缺失括号,或者需要自定义函数名读法,那么手写解析器往往更灵活。
手写分词与递归下降解析
如果不希望引入 SymPy,或者想完全掌控解析过程,可以自己实现一个轻量级解析器。第一步是分词,也就是把字符串切分成数字、变量名、函数名和运算符。下面使用正则表达式完成这个工作,并忽略空格。
import re
TOKEN_RE = re.compile(r'\s*(?:(\d+\.?\d*)|([A-Za-z_]\w*)|([+\-*/^()]))')
def tokenize(text):
tokens = []
idx = 0
while idx < len(text):
match = TOKEN_RE.match(text, idx)
if not match:
raise ValueError('无法识别的字符: ' + text[idx])
if match.group(1):
tokens.append(('NUMBER', float(match.group(1))))
elif match.group(2):
tokens.append(('IDENT', match.group(2)))
else:
tokens.append(('OP', match.group(3)))
idx = match.end()
tokens.append(('EOF', None))
return tokens
class Parser:
def __init__(self, tokens):
self.tokens = tokens
self.pos = 0
def peek(self):
return self.tokens[self.pos]
def consume(self):
token = self.tokens[self.pos]
self.pos += 1
return token
def parse_expression(self):
node = self.parse_add_sub()
if self.peek()[0] != 'EOF':
raise ValueError('表达式存在多余内容')
return node
def parse_add_sub(self):
node = self.parse_mul_div()
while self.peek()[0] == 'OP' and self.peek()[1] in ('+', '-'):
op = self.consume()[1]
right = self.parse_mul_div()
node = ('BINOP', op, node, right)
return node
def parse_mul_div(self):
node = self.parse_unary()
while self.peek()[0] == 'OP' and self.peek()[1] in ('*', '/'):
op = self.consume()[1]
right = self.parse_unary()
node = ('BINOP', op, node, right)
return node
def parse_unary(self):
if self.peek()[0] == 'OP' and self.peek()[1] == '-':
self.consume()
return ('UNARY', '-', self.parse_unary())
return self.parse_power()
def parse_power(self):
node = self.parse_atom()
if self.peek()[0] == 'OP' and self.peek()[1] == '^':
self.consume()
right = self.parse_unary()
node = ('BINOP', '^', node, right)
return node
def parse_atom(self):
token = self.peek()
if token[0] == 'NUMBER':
self.consume()
return ('NUMBER', token[1])
if token[0] == 'IDENT':
self.consume()
name = token[1]
if self.peek()[0] == 'OP' and self.peek()[1] == '(':
self.consume()
args = []
if not (self.peek()[0] == 'OP' and self.peek()[1] == ')'):
args.append(self.parse_expression())
while self.peek()[0] == 'OP' and self.peek()[1] == ',':
self.consume()
args.append(self.parse_expression())
if self.peek()[0] == 'OP' and self.peek()[1] == ')':
self.consume()
return ('FUNC', name, args)
return ('VAR', name)
if token[0] == 'OP' and token[1] == '(':
self.consume()
node = self.parse_expression()
if self.peek()[0] == 'OP' and self.peek()[1] == ')':
self.consume()
return node
raise ValueError('表达式不完整')
解析器按照“加减、乘除、一元负号、幂、原子项”的顺序逐层下降。低优先级的运算放在上层,高优先级的运算放在下层。这样的分层方式可以让 2*x^2 先解析出幂,再解析出乘法,而不是错误地把 2*x 先算出来再平方。括号在 parse_atom 中递归调用 parse_expression,因此括号内的内容会被当作一个完整的子表达式处理。
函数调用的解析也是从 parse_atom 开始的。当遇到标识符且后面紧跟左括号时,解析器会收集参数,参数之间用逗号分隔。每个参数又是一个完整表达式,所以 sin(x+1) 这类嵌套写法可以正确识别。这样做的好处是后续生成文本时,能单独拿到函数名和参数列表,从而按函数类型套用不同读法。
这个解析器仍然比较简单,例如没有处理隐式乘法、科学计数法和复数,也不支持省略乘号的写法。但对于常见的数学表达式来说已经足够,而且代码结构清晰,容易扩展新的运算符或函数。
把解析树转换为自然语言文本
有了解析树之后,下一步是编写树到文本的转换函数。和 SymPy 方案类似,核心仍然是递归遍历节点,但这次节点类型是我们自己定义的结构。下面给出一个基础实现。
def tree_to_text(node):
tag = node[0]
if tag == 'NUMBER':
num = node[1]
return str(int(num)) if float(num).is_integer() else str(num)
if tag == 'VAR':
return node[1]
if tag == 'BINOP':
op = node[1]
left = tree_to_text(node[2])
right = tree_to_text(node[3])
if op == '+':
return left + ' 加上 ' + right
if op == '-':
return left + ' 减去 ' + right
if op == '*':
return left + ' 乘以 ' + right
if op == '/':
return left + ' 除以 ' + right
if op == '^':
if node[3] == ('NUMBER', 2.0):
return left + '的平方'
return left + '的' + right + '次方'
if tag == 'UNARY':
return '负' + tree_to_text(node[1])
if tag == 'FUNC':
name = node[1]
args = '、'.join(tree_to_text(arg) for arg in node[2])
func_names = {'sin': '正弦', 'cos': '余弦', 'log': '对数'}
return args + '的' + func_names.get(name, name)
return str(node)
tokens = tokenize('sin(x) + 2*x^2 - y/3')
tree = Parser(tokens).parse_expression()
print(tree_to_text(tree))
这个函数把叶子节点转换为变量名或数字文本,数字会尽量去掉多余的小数点。对于二元运算,根据运算符选择“加上”“减去”“乘以”“除以”等中文连接词。幂运算做了特殊处理,指数为 2 时直接输出“的平方”,其他情况输出“的n次方”。一元负号会输出“负”前缀,避免出现“减去”结构不完整的问题。
函数处理部分使用了中文函数名映射表。sin 读作“正弦”,cos 读作“余弦”,log 简单读作“对数”。当参数多于一个时,用顿号连接,例如 log(a,b) 会生成“a、b的对数”。这种读法虽然可用,但和数学教师口头表达“以a为底b的对数”还有差距。要做得更自然,需要针对 log 写独立的模板逻辑。
整体来看,树到文本的转换并不复杂,真正影响体验的是细节。比如系数为 1 时不应该读“1乘以x”,而应该直接读“x”;系数为 -1 时读“负x”比“负1乘以x”更自然。幂指数为 3 时可以读“的立方”,为 0.5 时可以读“的平方根”。这些规则可以逐步沉淀到配置表和单元测试里。
优化输出质量与测试建议
一个能用的表达式朗读模块,不能只满足于输出正确,还要尽量符合人的语言习惯。常见的优化点包括:整数数字避免输出 2.0;系数 1 和 -1 做省略;除法尽量用“除以”而不是负幂;平方、立方、平方根等常见幂使用固定读法;函数名采用教学领域常用说法。
风格控制也很重要。不同场景对同一公式可能有不同读法。例如教学场景希望强调运算顺序,可以读成“2乘以x加1的和”;简洁场景则允许读成“2x加1”。因此可以在 tree_to_text 中增加 mode 参数,根据 mode 切换连接词、括号提示和数词风格。比如 mode 为 teaching 时,在乘法和加法嵌套处补充“的和”“的积”等提示语。
为了验证生成效果,建议建立一组固定输入输出用例。例如 sin(x)+x^2 应输出“x的正弦加上x的平方”,2*(x+1) 应输出“2乘以x加1的和”,x/(y+1) 应输出“x除以y加1的和”。每增加一批新模板,就跑一遍测试,避免修改乘法逻辑时意外破坏加法或函数读法。
如果还需要把自然语言进一步交给语音合成引擎,可以在生成阶段保留标点符号和停顿信息。例如在“加上”“减去”前后加空格,或者在长表达式输出时插入逗号。这样处理后,TTS 朗读时会更接近真人讲解,而不是机械地逐字念出。
Python数学表达式自然语言生成表达式解析修改时间:2026-10-01 16:03:12