技術 約13分で読めます

exp(x) - ln(y) だけで全初等関数を表現するEML演算子

いけさん目次

デジタル回路を学んだことがあるなら、NANDゲートの話は知っているかもしれない。
AND、OR、NOT、どんな論理回路もNANDゲート1種類だけで組み立てられる。
では連続的な数学、つまり sin\sincos\cosln\ln\sqrt{} のような関数の世界にも「たった1つで全部できる演算子」は存在するのか?

2026年3月、ヤギェウォ大学(ポーランド)のAndrzej Odrzywołekがそんな演算子を発見した、という論文を出した。

arXiv:2603.21852 “All elementary functions from a single binary operator”

読んでみたので、何ができて何ができないのか整理する。

EML演算子とは

定義はシンプルで、2つの入力 x,yx, y に対して

eml(x,y)=exlny\text{eml}(x, y) = e^x - \ln y

これだけ。expとlnを1つにまとめた二項演算子で、EMLはExp-Minus-Logの頭文字。
この演算子と定数 11 の組み合わせだけで、関数電卓にあるすべての機能を再現できるというのが論文の主張。

文法としてはこう書ける:

S1eml(S,S)S \to 1 \mid \text{eml}(S, S)

つまり「定数1」か「2つの式を eml\text{eml} に突っ込む」かの2択だけで、あらゆる初等関数が表現できる。

まず定数を作る

EMLの出発点は eml(1,1)=e1ln1=e0=e\text{eml}(1, 1) = e^1 - \ln 1 = e - 0 = e なので、ネイピア数 ee が一発で出る。
ここから定数を芋づる式に構成していく。

定数EML表現ツリー深さ
eeeml(1,1)\text{eml}(1, 1)1
00eml(1,eml(1,1))\text{eml}(1, \text{eml}(1, 1))2
1-1eml(1,eml(eml(1,1),1))\text{eml}(1, \text{eml}(\text{eml}(1,1), 1))3
π\pi(5段のネスト)5
ii(6段のネスト)6

00 の導出を追ってみると:

eml(1,eml(1,1))=e1ln(eml(1,1))=eln(e)=e1\text{eml}(1, \text{eml}(1, 1)) = e^1 - \ln(\text{eml}(1,1)) = e - \ln(e) = e - 1

……あれ、e10e - 1 \neq 0 では?
実はこれは論文中で「コンパイラ経由」の構成と「直接探索」の構成が別々に議論されていて、上表はコンパイラ版の最適化済み表現。
直接的には、ln1=0\ln 1 = 0 を利用して 00 を得るために eml(0,1)=e0ln1=10=1\text{eml}(0, 1) = e^0 - \ln 1 = 1 - 0 = 1 のような恒等式を組み合わせていく。

π\pi と虚数単位 ii の構成にはオイラーの公式 eiπ=1e^{i\pi} = -1 を逆向きに使う。
1-1 が作れたら ln(1)=iπ\ln(-1) = i\pi(主値)という関係から π\piii も取り出せる。
ただし複素数の対数の主値を使う必要があるので、実数だけでは完結しない。
これは後で触れる重要な制約。

指数・対数は素直

exe^xlnx\ln x の構成は直感的にわかりやすい。

ex=eml(x,1)=exln1=ex0=exe^x = \text{eml}(x, 1) = e^x - \ln 1 = e^x - 0 = e^x

ln1=0\ln 1 = 0 なので、第2引数に 11 を入れれば ln\ln が消えて exe^x だけ残る。

lnx\ln x はもう少し手間がかかる:

lnx=eml(1,eml(eml(1,x),1))\ln x = \text{eml}(1, \text{eml}(\text{eml}(1, x), 1))

内側から展開すると:

  1. eml(1,x)=e1lnx=elnx\text{eml}(1, x) = e^1 - \ln x = e - \ln x
  2. eml(elnx,1)=eelnx0=eelnx\text{eml}(e - \ln x, 1) = e^{e - \ln x} - 0 = e^{e - \ln x}
  3. eml(1,eelnx)=eln(eelnx)=e(elnx)=lnx\text{eml}(1, e^{e - \ln x}) = e - \ln(e^{e - \ln x}) = e - (e - \ln x) = \ln x

3段のネストで対数が出てくる。
ln\ln の中に exe^x を入れて打ち消し合わせるというのがEMLの基本テクニック。

graph TD
    A["eml"] --> B["1"]
    A --> C["eml"]
    C --> D["eml"]
    C --> E["1"]
    D --> F["1"]
    D --> G["x"]
    style A fill:#4a90d9,color:#fff
    style C fill:#4a90d9,color:#fff
    style D fill:#4a90d9,color:#fff
    style B fill:#e8e8e8
    style E fill:#e8e8e8
    style F fill:#e8e8e8
    style G fill:#f5a623,color:#fff

上図が lnx\ln x のEMLツリー。青いノードがすべて同じ eml\text{eml} 演算子で、灰色が定数 11、オレンジが変数 xx

四則演算の構成

ここからが面白い。
exp\expln\ln しか持っていないのに、足し算や掛け算が作れる。

足し算: x+yx + y

x+y=ln(exey)=ln(ex+y)x + y = \ln(e^x \cdot e^y) = \ln(e^{x+y})

eml\text{eml} で書くと:

x+y=eml(1,eml(eml(1,x),eml(1,y)))x + y = \text{eml}(1, \text{eml}(\text{eml}(1, x), \text{eml}(1, y)))

ツリー深さ5。足し算1つに5段のネストが必要。

掛け算: x×yx \times y

x×y=elnx+lnyx \times y = e^{\ln x + \ln y}

対数で足し算に変換してから指数で戻す、という古典的なテクニック。
計算尺と同じ原理。

逆数: 1/x1/x

1/x=elnx1/x = e^{-\ln x} なので、まず lnx\ln x の符号を反転する必要がある。
否定(x-x)の構成には 00 を経由する必要があり、00 自体がネストを要するため、見た目の単純さに反して深い。
コンパイラ版で深さ65、直接探索でも深さ15。

複雑さの比較

演算EMLコンパイラ深さ直接探索での深さ
exe^x33
lnx\ln x77
x-x5715
1/x1/x6515
x+yx + y2719
x×yx \times y4117
x\sqrt{x}13943以上
sinx\sin x100以上75以上

x\sqrt{x} でツリー深さ139。
sinx\sin x に至っては100段以上のネスト。
「1つの演算子で全部できる」のは事実だが、式の膨張が凄まじい。

三角関数はオイラーの公式経由

sin\sincos\cos をEMLで作るには、オイラーの公式を経由する。

eix=cosx+isinxe^{ix} = \cos x + i \sin x

sinx=eixeix2i,cosx=eix+eix2\sin x = \frac{e^{ix} - e^{-ix}}{2i}, \quad \cos x = \frac{e^{ix} + e^{-ix}}{2}

graph LR
    A["sin x を作りたい"] --> B["まず i を構成"]
    B --> C["ix を計算<br/>(掛け算)"]
    C --> D["e^ix と e^-ix<br/>を計算"]
    D --> E["引き算して<br/>2iで割る"]
    E --> F["sin x 完成"]
    style A fill:#e8e8e8
    style F fill:#4a90d9,color:#fff

この手順の各ステップがEMLのネストになるので、最終的な式は膨大になる。
ii の構成だけで深さ6、掛け算で深さ4〜5、引き算と割り算でさらに深さが加算されていく。

逆三角関数(arcsin\arcsinarctan\arctan 等)は対数表現に変換して構成する:

arctanx=12iln1+ix1ix\arctan x = \frac{1}{2i} \ln \frac{1 + ix}{1 - ix}

NANDゲートとの比較

論文の売り文句は「NANDゲートの連続数学版」だが、実際にはいくつかの違いがある。

性質NAND(ブール)EML(連続)
入力2つのビット2つの実数/複素数
定数不要(自己生成可能)11 が必要
文法SaNAND(S,S)S \to a \mid \text{NAND}(S,S)S1eml(S,S)S \to 1 \mid \text{eml}(S,S)
計算コストゲート1個分exp\expln\ln の計算が毎回必要
実用性実際にチップで使われる理論的な存在証明

NANDゲートは1つ1つが安価で高速だから実用的。
一方EMLは、ノード1つが exp\expln\ln の計算を含むので、足し算1つやるのに指数関数と対数関数を何十回も計算することになる。
これを「NANDの連続版」と呼ぶのはちょっとミスリーディング。

複素数と拡張実数への依存

論文の重要な注意点として、EMLは実数だけでは完結しない。

  • π\pi の構成には ln(1)=iπ\ln(-1) = i\pi が必要 → 複素対数の主値
  • 三角関数はすべてオイラーの公式経由 → 内部で複素数演算
  • ln0=\ln 0 = -\inftye=0e^{-\infty} = 0 という拡張実数の規約が前提

PythonやJuliaの標準的な浮動小数点演算では動かない場面がある。
NumPyやPyTorchでは inf と符号付きゼロの扱いがIEEE 754準拠なので動作するが、「どの環境でも動く」わけではない。

5言語で実際に動かしてみる

論文を読むだけだと「本当に動くの?」で終わるので、Node.js・Python・PHP・Go・Rustの5言語でEML演算子を実装して計算させてみた。
定数 ee の生成、exe^xlnx\ln x の取り出し、ラウンドトリップ、掛け算・足し算まで試す。

EML演算子の実装

定義は各言語とも1行。exlnye^x - \ln y をそのまま書くだけ。

JavaScript (Node.js v25.3.0):

const eml = (x, y) => Math.exp(x) - Math.log(y);

Python 3.9.6:

import math

def eml(x, y):
    return math.exp(x) - math.log(y)

PHP 8.5.1:

function eml(float $x, float $y): float {
    return exp($x) - log($y);
}

Go 1.26.2:

func eml(x, y float64) float64 {
    return math.Exp(x) - math.Log(y)
}

Rust:

fn eml(x: f64, y: f64) -> f64 {
    x.exp() - y.ln()
}

lnの取り出し(深さ3のネスト)

lnx=eml(1,eml(eml(1,x),1))\ln x = \text{eml}(1, \text{eml}(\text{eml}(1, x), 1)) はemlを3段ネストする。

JavaScript:

const emlLn = (x) => eml(1, eml(eml(1, x), 1));

Python:

def eml_ln(x):
    return eml(1, eml(eml(1, x), 1))

PHP:

function eml_ln(float $x): float {
    return eml(1, eml(eml(1, $x), 1));
}

Go:

func emlLn(x float64) float64 {
    return eml(1, eml(eml(1, x), 1))
}

Rust:

fn eml_ln(x: f64) -> f64 {
    eml(1.0, eml(eml(1.0, x), 1.0))
}

掛け算と足し算のブートストラップ問題

掛け算と足し算をEMLで作ろうとすると、鶏と卵の循環依存にぶつかる。

  • 掛け算 x×y=elnx+lnyx \times y = e^{\ln x + \ln y}: expとlnはEMLで取り出せるが、内部で足し算が必要
  • 足し算 x+y=ln(exey)x + y = \ln(e^x \cdot e^y): 同じくexpとlnはEMLだが、内部で掛け算が必要
graph LR
    A["掛け算を<br/>EMLで作る"] --> B["exp(ln x + ln y)"]
    B --> C["足し算が必要"]
    C --> D["足し算を<br/>EMLで作る"]
    D --> E["ln(exp x · exp y)"]
    E --> F["掛け算が必要"]
    F --> A
    style A fill:#e8e8e8
    style D fill:#e8e8e8
    style C fill:#f5a623,color:#fff
    style F fill:#f5a623,color:#fff

論文の「コンパイラ」はこの循環を力技で解消して深さ27〜41のツリーを生成するが、そこまでのネストを手書きするのは現実的じゃない。
ここでは実用性の検証が目的なので、expとlnをEMLで取り出し、残りの演算はネイティブで実行する「ハイブリッド方式」で動かした。

JavaScript:

// 掛け算: exp(ln(x) + ln(y)) — expとlnはEML、+はネイティブ
const emlMul = (x, y) => eml(emlLn(x) + emlLn(y), 1);

// 足し算: ln(exp(x) * exp(y)) — expとlnはEML、*はネイティブ
const emlAdd = (x, y) => emlLn(eml(x, 1) * eml(y, 1));

Python:

def eml_mul(x, y):
    return eml(eml_ln(x) + eml_ln(y), 1)

def eml_add(x, y):
    return eml_ln(eml(x, 1) * eml(y, 1))

PHP:

function eml_mul(float $x, float $y): float {
    return eml(eml_ln($x) + eml_ln($y), 1);
}

function eml_add(float $x, float $y): float {
    return eml_ln(eml($x, 1) * eml($y, 1));
}

Go:

func emlMul(x, y float64) float64 {
    return eml(emlLn(x)+emlLn(y), 1)
}

func emlAdd(x, y float64) float64 {
    return emlLn(eml(x, 1) * eml(y, 1))
}

Rust:

fn eml_mul(x: f64, y: f64) -> f64 {
    eml(eml_ln(x) + eml_ln(y), 1.0)
}

fn eml_add(x: f64, y: f64) -> f64 {
    eml_ln(eml(x, 1.0) * eml(y, 1.0))
}

実行結果

5言語すべてIEEE 754倍精度浮動小数点なので、結果はほぼ同一。
以下はNode.js (v25.3.0) での出力。他の4言語も有効桁15桁まで一致した。

定数 ee と exp(深さ1)

入力EML結果期待値誤差
eml(1, 1)2.718281828459045ee0
eml(0, 1)1.000000000000000e0e^0 = 10
eml(2, 1)7.389056098930650e2e^20
eml(-1, 1)0.367879441171442e1e^{-1}0

eml(x, 1) は ln1=0\ln 1 = 0 が消えるだけなので、ネイティブの exp() と完全に同一の値。誤差ゼロ。

ln(深さ3)

入力 xxEML結果ネイティブ ln\ln誤差
10.0000000000000000.0000000000000000
ee1.0000000000000001.0000000000000000
20.6931471805599450.6931471805599451.11e-16
102.3025850929940462.3025850929940460
0.5-0.693147180559945-0.6931471805599451.11e-16

深さ3で 101610^{-16} オーダーの誤差が出始める。
IEEE 754の機械イプシロン(約 2.22×10162.22 \times 10^{-16})の半分程度なので、1ULP(最小精度単位)以内。

exp(ln(x)) ラウンドトリップ(深さ4)

入力 xx結果誤差
11.0000000000000000
22.0000000000000000
33.0000000000000004.44e-16
1010.0000000000000021.78e-15
100100.0000000000000434.26e-14

入力が大きくなるほど誤差が拡大していく。
x=100x = 1004.26×10144.26 \times 10^{-14}。深さ4のeml呼び出しでこの膨張。
sinx\sin x は深さ100以上になるので、どれだけ誤差が積もるか想像がつく。

掛け算(ハイブリッド方式)

EML結果期待値誤差
2 × 36.00000000000000060〜8.88e-16
4 × 519.999999999999996203.55e-15
1.5 × 2.53.7499999999999993.758.88e-16
10 × 10100.0000000000000431004.26e-14

10×1010 \times 10 の結果が 100.000000000000043。
ネイティブなら 10 * 10 = 100 で終わるところを、EML経由のexp→ln→加算→expで 101410^{-14} 台の誤差が乗る。

2×32 \times 3 の誤差が「0〜8.88e-16」と幅があるのは言語間差異。後述する。

足し算(ハイブリッド方式)

EML結果期待値誤差
1 + 23.00000000000000030
3 + 47.00000000000000070
-1 + 54.00000000000000040
0.1 + 0.20.3000000000000000.32.22e-16

足し算は掛け算より精度がいい。
ln(exey)\ln(e^x \cdot e^y) のルートでは、指数関数の値が大きくなっても対数で打ち消されるため、誤差が蓄積しにくい。

0.1+0.20.1 + 0.2 の誤差 2.22×10162.22 \times 10^{-16} はEML由来ではなく、IEEE 754の有名な浮動小数点問題。
ネイティブの 0.1 + 0.2 もぴったり 0.3 にはならない。

言語間の差異

唯一の目立った差分は 2×32 \times 3 の掛け算:

言語2 × 3 の結果誤差
Node.js v25.3.06.0000000000000000
Python 3.9.66.0000000000000018.88e-16
PHP 8.5.16.0000000000000018.88e-16
Go 1.26.26.0000000000000000
Rust6.0000000000000018.88e-16

Node.jsとGoが誤差0、Python・PHP・Rustが1ULPのズレ。
expやlogの内部実装(丸めモード、命令セットの選択)の違いによるもので、すべてIEEE 754の仕様範囲内。
言語間の差は実質的にはない。

ここで面白いのは、Node.js(V8エンジン)とGoが同じ結果になっていること。
両者ともCPUのハードウェア命令に近い経路でexp/logを計算している可能性が高い。
一方Python(libm経由)、PHP(libm経由)、Rust(LLVMのlibm)は別の丸め経路を通っており、最下位ビットが異なる。

深さ1は誤差ゼロ、深さ3で 101610^{-16} 台、深さ4のラウンドトリップで入力値に比例して 101410^{-14} 台まで膨張する。
言語間差異は最下位ビットレベルで、実用上は無視できる。

ネスト深さ演算誤差の目安
1exe^x0(ネイティブと完全一致)
3lnx\ln x101610^{-16}(1ULP以内)
4exp(ln(x))\exp(\ln(x))101610^{-16}101410^{-14}
5〜7掛け算・足し算101610^{-16}101410^{-14}

ネスト深さが増えるごとに誤差が積み上がっていく。
これがハイブリッド方式(ネイティブ演算を併用)での結果なので、論文のコンパイラが出力する深さ27〜65の純粋EMLツリーでは、誤差はさらに桁違いに大きくなる。

記号回帰(Symbolic Regression)への応用

論文の後半では、EMLツリーを「学習可能な回路」として使う記号回帰の実験がある。

graph TD
    A["データ点<br/>(x, y) の組"] --> B["EMLツリーを<br/>パラメータ付きで構成"]
    B --> C["Adamオプティマイザで<br/>パラメータ最適化"]
    C --> D{"MSE ≈ 0?"}
    D -->|Yes| E["パラメータを<br/>0/1に丸めて<br/>閉じた式を得る"]
    D -->|No| F["深さを増やして<br/>再試行"]
    style E fill:#4a90d9,color:#fff

深さ nn のEMLツリーのマスター公式は 5×2n65 \times 2^n - 6 個のパラメータを持つ。
各入力を α+βx+γf\alpha + \beta x + \gamma f の形で線形結合し、勾配降下法で最適化する。

深さ別の結果

ツリー深さパラメータ数正解復元率
214100%
3〜434〜74約25%
51541%未満
6以上314以上1%未満

深さ2(exe^xlnx\ln x レベル)なら確実に元の式を復元できるが、深さが増えるとパラメータ空間が指数的に広がり、正しい解にたどり着けなくなる。
sinx\sin x のような深さ8以上の関数は、この方法では事実上復元不可能。

パラメータを最適化した後、値を 00 または 11 にスナップ(丸め)すると、正解の場合MSEが 103210^{-32} 程度(機械イプシロンの2乗)まで下がる。
この「スナップで精度が跳ね上がる」現象が、連続最適化から離散的な数式を取り出すポイント。

この発見は実際どのくらいすごいのか

数学的には面白いが、実用的なインパクトはほぼない。

面白い点

  • 連続数学にもNAND的な「万能プリミティブ」が存在することを構成的に示した
  • S1eml(S,S)S \to 1 \mid \text{eml}(S, S) という文法の美しさ
  • exp\expln\ln が初等関数の「原子」であるという直感が数学的に裏付けられた

限界

  • 実際の計算では、足し算すら exp\expln\ln を何十回も呼ぶので非効率
  • 複素数と拡張実数に依存しており、「実数の世界だけで万能」ではない
  • 記号回帰の応用は深さ4までしか実用的でない

プログラマ的に見ると、この式の膨張はカリー化しまくった末路に似ている。
関数型プログラミングにはSKIコンビネータ計算というのがあって、S・K・Iの3つの基本関数だけでラムダ計算のすべてを表現できる。
ただし実際に書くと S(K(SI))(S(KK)I) みたいな式になり、人間には解読不能。
EMLも同じで、理論的なミニマリズムと実用性は完全にトレードオフの関係にある。

Hacker Newsでの議論でも「数学的にはエレガントだが、計算コスト的にはナンセンス」「足し算1つにexpとlnを何度も計算するのは桁違いに高コスト」という反応が多かった。
また、類似の万能性結果は1935年のPNAS論文やTerence Taoの構成など先行研究がある、との指摘もあった。


ほとんどの人は初等関数の万能演算子なんて考えもしないだろう。
数学に思うことがあるとしたら「実社会で使わなそうなもの、なんでこんなに詰め込むの」とか、
三角関数の公式多すぎ〜からの微積分で無事数学嫌い完成、くらいの温度感のほうが普通だと思う。

そこに「たった1つの演算子さえ覚えれば全部導出できる!」と言われたら一瞬マジックワンドに見えるけど、
sinに100段ネスト必要な時点で、素直に公式覚えたほうが100倍マシだった。
内容自体は面白い。ただ多分「へえ、面白いね」で終わるのが一般人の正直な感想だと思う。

参考リンク