exp(x) - ln(y) だけで全初等関数を表現するEML演算子
目次
デジタル回路を学んだことがあるなら、NANDゲートの話は知っているかもしれない。
AND、OR、NOT、どんな論理回路もNANDゲート1種類だけで組み立てられる。
では連続的な数学、つまり 、、、 のような関数の世界にも「たった1つで全部できる演算子」は存在するのか?
2026年3月、ヤギェウォ大学(ポーランド)のAndrzej Odrzywołekがそんな演算子を発見した、という論文を出した。
arXiv:2603.21852 “All elementary functions from a single binary operator”
読んでみたので、何ができて何ができないのか整理する。
EML演算子とは
定義はシンプルで、2つの入力 に対して
これだけ。expとlnを1つにまとめた二項演算子で、EMLはExp-Minus-Logの頭文字。
この演算子と定数 の組み合わせだけで、関数電卓にあるすべての機能を再現できるというのが論文の主張。
文法としてはこう書ける:
つまり「定数1」か「2つの式を に突っ込む」かの2択だけで、あらゆる初等関数が表現できる。
まず定数を作る
EMLの出発点は なので、ネイピア数 が一発で出る。
ここから定数を芋づる式に構成していく。
| 定数 | EML表現 | ツリー深さ |
|---|---|---|
| 1 | ||
| 2 | ||
| 3 | ||
| (5段のネスト) | 5 | |
| (6段のネスト) | 6 |
の導出を追ってみると:
……あれ、 では?
実はこれは論文中で「コンパイラ経由」の構成と「直接探索」の構成が別々に議論されていて、上表はコンパイラ版の最適化済み表現。
直接的には、 を利用して を得るために のような恒等式を組み合わせていく。
と虚数単位 の構成にはオイラーの公式 を逆向きに使う。
が作れたら (主値)という関係から も も取り出せる。
ただし複素数の対数の主値を使う必要があるので、実数だけでは完結しない。
これは後で触れる重要な制約。
指数・対数は素直
と の構成は直感的にわかりやすい。
なので、第2引数に を入れれば が消えて だけ残る。
はもう少し手間がかかる:
内側から展開すると:
3段のネストで対数が出てくる。
の中に を入れて打ち消し合わせるというのが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
上図が のEMLツリー。青いノードがすべて同じ 演算子で、灰色が定数 、オレンジが変数 。
四則演算の構成
ここからが面白い。
と しか持っていないのに、足し算や掛け算が作れる。
足し算:
で書くと:
ツリー深さ5。足し算1つに5段のネストが必要。
掛け算:
対数で足し算に変換してから指数で戻す、という古典的なテクニック。
計算尺と同じ原理。
逆数:
なので、まず の符号を反転する必要がある。
否定()の構成には を経由する必要があり、 自体がネストを要するため、見た目の単純さに反して深い。
コンパイラ版で深さ65、直接探索でも深さ15。
複雑さの比較
| 演算 | EMLコンパイラ深さ | 直接探索での深さ |
|---|---|---|
| 3 | 3 | |
| 7 | 7 | |
| 57 | 15 | |
| 65 | 15 | |
| 27 | 19 | |
| 41 | 17 | |
| 139 | 43以上 | |
| 100以上 | 75以上 |
でツリー深さ139。
に至っては100段以上のネスト。
「1つの演算子で全部できる」のは事実だが、式の膨張が凄まじい。
三角関数はオイラーの公式経由
や をEMLで作るには、オイラーの公式を経由する。
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のネストになるので、最終的な式は膨大になる。
の構成だけで深さ6、掛け算で深さ4〜5、引き算と割り算でさらに深さが加算されていく。
逆三角関数(、 等)は対数表現に変換して構成する:
NANDゲートとの比較
論文の売り文句は「NANDゲートの連続数学版」だが、実際にはいくつかの違いがある。
| 性質 | NAND(ブール) | EML(連続) |
|---|---|---|
| 入力 | 2つのビット | 2つの実数/複素数 |
| 定数 | 不要(自己生成可能) | が必要 |
| 文法 | ||
| 計算コスト | ゲート1個分 | と の計算が毎回必要 |
| 実用性 | 実際にチップで使われる | 理論的な存在証明 |
NANDゲートは1つ1つが安価で高速だから実用的。
一方EMLは、ノード1つが と の計算を含むので、足し算1つやるのに指数関数と対数関数を何十回も計算することになる。
これを「NANDの連続版」と呼ぶのはちょっとミスリーディング。
複素数と拡張実数への依存
論文の重要な注意点として、EMLは実数だけでは完結しない。
- の構成には が必要 → 複素対数の主値
- 三角関数はすべてオイラーの公式経由 → 内部で複素数演算
- 、 という拡張実数の規約が前提
PythonやJuliaの標準的な浮動小数点演算では動かない場面がある。
NumPyやPyTorchでは inf と符号付きゼロの扱いがIEEE 754準拠なので動作するが、「どの環境でも動く」わけではない。
5言語で実際に動かしてみる
論文を読むだけだと「本当に動くの?」で終わるので、Node.js・Python・PHP・Go・Rustの5言語でEML演算子を実装して計算させてみた。
定数 の生成、 と の取り出し、ラウンドトリップ、掛け算・足し算まで試す。
EML演算子の実装
定義は各言語とも1行。 をそのまま書くだけ。
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のネスト)
は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で作ろうとすると、鶏と卵の循環依存にぶつかる。
- 掛け算 : expとlnはEMLで取り出せるが、内部で足し算が必要
- 足し算 : 同じく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桁まで一致した。
定数 と exp(深さ1)
| 入力 | EML結果 | 期待値 | 誤差 |
|---|---|---|---|
| eml(1, 1) | 2.718281828459045 | 0 | |
| eml(0, 1) | 1.000000000000000 | = 1 | 0 |
| eml(2, 1) | 7.389056098930650 | 0 | |
| eml(-1, 1) | 0.367879441171442 | 0 |
eml(x, 1) は が消えるだけなので、ネイティブの exp() と完全に同一の値。誤差ゼロ。
ln(深さ3)
| 入力 | EML結果 | ネイティブ | 誤差 |
|---|---|---|---|
| 1 | 0.000000000000000 | 0.000000000000000 | 0 |
| 1.000000000000000 | 1.000000000000000 | 0 | |
| 2 | 0.693147180559945 | 0.693147180559945 | 1.11e-16 |
| 10 | 2.302585092994046 | 2.302585092994046 | 0 |
| 0.5 | -0.693147180559945 | -0.693147180559945 | 1.11e-16 |
深さ3で オーダーの誤差が出始める。
IEEE 754の機械イプシロン(約 )の半分程度なので、1ULP(最小精度単位)以内。
exp(ln(x)) ラウンドトリップ(深さ4)
| 入力 | 結果 | 誤差 |
|---|---|---|
| 1 | 1.000000000000000 | 0 |
| 2 | 2.000000000000000 | 0 |
| 3 | 3.000000000000000 | 4.44e-16 |
| 10 | 10.000000000000002 | 1.78e-15 |
| 100 | 100.000000000000043 | 4.26e-14 |
入力が大きくなるほど誤差が拡大していく。
で 。深さ4のeml呼び出しでこの膨張。
は深さ100以上になるので、どれだけ誤差が積もるか想像がつく。
掛け算(ハイブリッド方式)
| 式 | EML結果 | 期待値 | 誤差 |
|---|---|---|---|
| 2 × 3 | 6.000000000000000 | 6 | 0〜8.88e-16 |
| 4 × 5 | 19.999999999999996 | 20 | 3.55e-15 |
| 1.5 × 2.5 | 3.749999999999999 | 3.75 | 8.88e-16 |
| 10 × 10 | 100.000000000000043 | 100 | 4.26e-14 |
の結果が 100.000000000000043。
ネイティブなら 10 * 10 = 100 で終わるところを、EML経由のexp→ln→加算→expで 台の誤差が乗る。
の誤差が「0〜8.88e-16」と幅があるのは言語間差異。後述する。
足し算(ハイブリッド方式)
| 式 | EML結果 | 期待値 | 誤差 |
|---|---|---|---|
| 1 + 2 | 3.000000000000000 | 3 | 0 |
| 3 + 4 | 7.000000000000000 | 7 | 0 |
| -1 + 5 | 4.000000000000000 | 4 | 0 |
| 0.1 + 0.2 | 0.300000000000000 | 0.3 | 2.22e-16 |
足し算は掛け算より精度がいい。
のルートでは、指数関数の値が大きくなっても対数で打ち消されるため、誤差が蓄積しにくい。
の誤差 はEML由来ではなく、IEEE 754の有名な浮動小数点問題。
ネイティブの 0.1 + 0.2 もぴったり 0.3 にはならない。
言語間の差異
唯一の目立った差分は の掛け算:
| 言語 | 2 × 3 の結果 | 誤差 |
|---|---|---|
| Node.js v25.3.0 | 6.000000000000000 | 0 |
| Python 3.9.6 | 6.000000000000001 | 8.88e-16 |
| PHP 8.5.1 | 6.000000000000001 | 8.88e-16 |
| Go 1.26.2 | 6.000000000000000 | 0 |
| Rust | 6.000000000000001 | 8.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で 台、深さ4のラウンドトリップで入力値に比例して 台まで膨張する。
言語間差異は最下位ビットレベルで、実用上は無視できる。
| ネスト深さ | 演算 | 誤差の目安 |
|---|---|---|
| 1 | 0(ネイティブと完全一致) | |
| 3 | (1ULP以内) | |
| 4 | 〜 | |
| 5〜7 | 掛け算・足し算 | 〜 |
ネスト深さが増えるごとに誤差が積み上がっていく。
これがハイブリッド方式(ネイティブ演算を併用)での結果なので、論文のコンパイラが出力する深さ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
深さ のEMLツリーのマスター公式は 個のパラメータを持つ。
各入力を の形で線形結合し、勾配降下法で最適化する。
深さ別の結果
| ツリー深さ | パラメータ数 | 正解復元率 |
|---|---|---|
| 2 | 14 | 100% |
| 3〜4 | 34〜74 | 約25% |
| 5 | 154 | 1%未満 |
| 6以上 | 314以上 | 1%未満 |
深さ2(、 レベル)なら確実に元の式を復元できるが、深さが増えるとパラメータ空間が指数的に広がり、正しい解にたどり着けなくなる。
のような深さ8以上の関数は、この方法では事実上復元不可能。
パラメータを最適化した後、値を または にスナップ(丸め)すると、正解の場合MSEが 程度(機械イプシロンの2乗)まで下がる。
この「スナップで精度が跳ね上がる」現象が、連続最適化から離散的な数式を取り出すポイント。
この発見は実際どのくらいすごいのか
数学的には面白いが、実用的なインパクトはほぼない。
面白い点
- 連続数学にもNAND的な「万能プリミティブ」が存在することを構成的に示した
- という文法の美しさ
- と が初等関数の「原子」であるという直感が数学的に裏付けられた
限界
- 実際の計算では、足し算すら と を何十回も呼ぶので非効率
- 複素数と拡張実数に依存しており、「実数の世界だけで万能」ではない
- 記号回帰の応用は深さ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倍マシだった。
内容自体は面白い。ただ多分「へえ、面白いね」で終わるのが一般人の正直な感想だと思う。