什么是可微编程
通过动画、动效增加 UI 表现力,作为前端或多或少都做过。这里以弹性阻尼动画的函数为例:
函数在
时是效果最好的。最终,实现成 JavaScript 代码:
function damping(x, max) {
let y = Math.abs(x);
// 下面的参数都是来源于公式用数值拟合的结果
y = 0.82231 * max / (1 + 4338.47 / Math.pow(y, 1.14791));
return Math.round(x < 0 ? -y : y);
}
如图 1 所示,2019 年 Julia Computing 团队发表论文《A Differentiable Programming System to Bridge Machine Learning and Scientific Computing》表示他们构建了一种可微编程系统,它能将自动微分内嵌于 Julia 语言,从而将其作为 Julia 语言的第一公民。如果将可微编程系统视为编程语言第一公民,那么不论是机器学习还是其它科学计算都将方便不少。Y Combinator Research 研究者 Michael Nielsen 对此非常兴奋,非常赞同 Andrej Karpathy 的观点。Karpathy 说:「我们正向前迈出了一步,与原来对程序有完整的定义不同,我们现在只是写一个大致的解决问题的框架,这样的框架会通过权重把解决过程参数化。如果我们有一个好的评估标准,那么最优化算法就能帮我们找到更好的解(参数)。」
这里 Karpathy 说的就是传统编程和可微编程的区别,可微编程会通过梯度下降等最优化方法自动搜索最优解。但这里有个问题,程序需要梯度才能向着最优前进,因此程序的很多部分都要求是可微的。鉴于这一点,很多人也就将机器学习(ML)称呼为可微编程了。
但是可微编程只能用于机器学习吗?它能不能扩展到其它领域,甚至成为编程语言的基本特性?答案是可以的,这就是 Julia 团队及 MIT 等其他研究机构正在尝试的。近年来,机器学习模型越来越精妙,展现出了很多科学计算的特性,侧面凸显了机器学习框架的强大能力。研究者表示,由于广泛的科学计算和机器学习领域在底层结构上都需要线性代数的支持,因此有可能以可微编程的形式,创造一种新的编程思想。下面,我们就一起进入这个全新的领域。
可微编程和自动微分的关系在开始可微编程之前,我想先简单回顾一下之前计算弹性阻尼的例子。你会发现
数值微分法:
只要 h 取很小的数值,比如0.0001,那么我们可以很方便求解导数,并且可以对用户隐藏求解过程,用户只要给出目标函数和要求解的梯度的变量,程序可以自动给出相应的梯度,这也是某种意义上的“自动微分”。而我们在 H5 弹性阻尼函数的求解过程中,使用的正式这种“自动微分”技术。数值微分法的弊端在于计算量大,由于是做拟合相当于我们要把公式
符号微分(Symbolic Differentiation)属符号计算的范畴,其计算结果是导函数的表达式。符号计算用于求解数学中的公式解(也称解析解)。和前文数值解不同的是,通过符号位分得到的是解的表达式而非具体的数值。根据基本函数的求导公式以及四则运算、复合函数的求导法则,符号微分算法可以得到任意可微函数的导数表达式。
然后将自变量的值代入导数公式,得到任意点处的导数值。符号微分计算出的表达式需要用字符串或其他数据结构存储,如表达式树。数学软件如 Mathematica,Maple,matlab 中实现了这种技术,python 语言的符号计算库也提供了这类算法。
对于深层复合函数,如机器学习中神经网络的映射函数,符号微分算法得到的导数计算公式将会非常冗长。这种冗长的情况,我们称为表达式膨胀(expression swell)。对于机器学习中的应用,不需要得到导数的表达式,而只需计算函数在某一点处的导数值,从而以参数的形式更新神经元的权重。因此,符号微分在计算冗余且成本高昂。例如:对于公式
如果采用符号微分算法,当n=1,2,3,4时的 ln 及其导数如图 3 所示。
自动微分不同于数值微分和符号位分,它是介于符号微分和数值微分之间的一种方法。数值微分一开始就代入数值近似求解,而符号微分直接对表达式进行推导,最后才代入自变量的值得到最终解。自动微分则是将符号微分应用于最基本的运算(或称原子操作),如常数,幂函数,指数函数,对数函数,三角函数等基本函数,代入自变量的值得到其导数值,作为中间结果进行保留。然后,再根据这些基本运算单元的求导结果计算出整个函数的导数值。
自动微分的灵活强,可实现完全向用户隐藏求导过程,由于它只对基本函数或常数运用符号微分法则,因此可以灵活地结合编程语言的循环、分支等结构,根据链式法则,借助于计算图计算出任意复杂函数的导数值。由于存在上述优点,该方法在现代深度学习库中得到广泛使用。Julia 的论文实现的 zygote 工具进行的可微编程,研究人员定义了一个损失函数,将点光源作为输入在图像上产生光照,如图 4 所示和参考图像进行对比。通过可微编程的方式自动计算和提取梯度,并用于更新点光源的位置:
julia> guess = PointLight(Vec3(1.0), 20000.0, Vec3(1.0, 2.0, -7.0))
julia> function loss_function(light)
rendered_color = raytrace(origin, direction, scene, light, eye_pos)
rendered_img = process_image(rendered_color, screen_size.w,
screen_size.h)
return mean((rendered_img .- reference_img).^2)
end
julia> gs = gradient(x -> loss_function(x, image), guess)
- 可微编程实现智能应用程序
在 Julia 之后 Swift 也推出了自己的可微编程,对,你没听错,就是 Apple 应用开发推荐编程语言 Swift。在 Swift 的可微编程提案中,研究人员提出了“智能应用程序”的概念。智能应用程序很智能:它们使用机器学习技术来增强用户体验。智能应用程序可以借助可微编程的强大能力,对应用的行为做出预测、提供建议并了解用户偏好,根据用户偏好智能调整应用的行为。
智能应用的核心是可微编程,自动微分可用于通过梯度下降系统地优化(即找到“好”值)参数。通过传统编程思想的算法优化,这些参数通常很难处理,要么是类似数值微分和符号微分中那样参数太多,要么是难以和应用的行为做关联。例如,我们要开发一个智能播放器,它尝试根据播放器内容类型和播放器的用户偏好自动调整播放速度。
enum PodcastCategory {
case comedy
case news
...
}
enum PodcastSection {
case advertisement
case introduction
case body
case conclusion
}
struct PodcastState {
let category: PodcastCategory
let section: PodcastSection
}
struct PodcastSpeedModel {
var minSpeed, maxSpeed: Float
var categoryMultipliers: [PodcastCategory: Float]
var sectionMultipliers: [PodcastSection: Float]
/// Returns a podcast speed multiplier prediction for the given podcast category
/// and section.
func prediction(for state: PodcastState) -> Float {
let speed = categoryMultipliers[state.category] * sectionMultipliers[state.section]
if speed < minSpeed { return minSpeed }
if speed > maxSpeed { return maxSpeed }
return speed
}
}
此播放器的播放速度参数为:minSpeed
、maxSpeed
、 categoryMultipliers
和sectionMultipliers
。根据我们的经验来判断的话,什么是好的参数?不同的用户可能会有不同的答案,无法根据用户偏好设定不同的参数值。 智能应用程序可以借助可微编程来确定个性化的参数值,如下:
- 让用户手动设置速度,并在用户改变速度时记录观察结果的参数值。
- 在收集到足够的观察值后,搜索参数值,使模型预测的速度接近用户的首选速度。如果找到这样的值,播放器会产生预测并自动设置速度。
“梯度下降”是执行这种搜索的算法,而支持可微编程的语言可以很容易地实现梯度下降。这是一些说明梯度下降的伪代码:
// 首先,我们需要一个梯度下降的目标函数来最小化,这里使用平均绝对误差: struct Observation { var podcastState: PodcastState var userSpeed: Float } func meanError(for model: PodcastSpeedModel, _ observations: [Observation]) -> Float { var error: Float = 0 for observation in observations { error += abs(model.prediction(for: observation.podcastState) - observation.userSpeed) } return error / Float(observations.count) } // 接下来,我们实现梯度下降算法。 var model = PodcastModel() let observations = storage.observations() for _ in 0..
做个小修改:
即可。
神经网络的拟合
虽然,到这里似乎已经完成了可微编程解决弹性阻尼函数拟合的问题,并找到了程序的算法解决方案,但是,回到智能化编程思想里,我脑海里浮现一个问题:图计算和神经网络能否解决这个问题呢?带着这个问题,我又定义了一个图计算的神经网络:
model = tf.keras.Sequential() # 添加层 # 注:input_dim(输入神经元个数)只需要在输入层重视设置,后面的网络可以自动推断出该层的对应输入 model.add(tf.keras.layers.Dense(units=10, input_dim=1, activation='selu')) model.add(tf.keras.layers.Dense(units=10, input_dim=1, activation='selu')) model.add(tf.keras.layers.Dense(units=10, input_dim=1, activation='selu')) model.add(tf.keras.layers.Dense(units=10, input_dim=1, activation='selu')) model.add(tf.keras.layers.Dense(units=10, input_dim=1, activation='selu')) model.add(tf.keras.layers.Dense(units=10, input_dim=1, activation='selu')) model.add(tf.keras.layers.Dense(units=10, input_dim=1, activation='selu')) model.add(tf.keras.layers.Dense(units=10, input_dim=1, activation='selu')) model.add(tf.keras.layers.Dense(units=10, input_dim=1, activation='selu')) model.add(tf.keras.layers.Dense(units=10, input_dim=1, activation='selu')) # 神经元个数 输入神经元个数 激活函数 model.add(tf.keras.layers.Dense(units=1, activation='selu')) # 输出神经元个数 # 2 设置优化器和损失函数 model.compile(optimizer='adam', loss='mse') # 优化器 损失函数(均方误差) # 3 训练 history = model.fit(t_x, t_y,epochs=2000) # 4 预测 y_pred = model.predict(t_x)
这里我用 TensorFlow 的
keras
API 定义了十个Dense
层,每层有十个神经元、一个输入维度和selu
非线性激活函数,再定义了一个神经元同样激活函数的Dense
层作为输出。这个定义是通过实验发现过多或过少的神经元都无法提升训练效果,太多的层也无法更好的优化准确率。模型的优化器如前面的示例也是adam
,损失函数也是平均方差损失函数mse
。训练过程:1/1 [==============================] - 1s 573ms/step - loss: 74593.5156 Epoch 2/2000 1/1 [==============================] - 0s 38ms/step - loss: 74222.5469 Epoch 3/2000 1/1 [==============================] - 0s 31ms/step - loss: 74039.7734 ... Epoch 1994/2000 1/1 [==============================] - 0s 26ms/step - loss: 0.3370 Epoch 1995/2000 1/1 [==============================] - 0s 25ms/step - loss: 0.3370 Epoch 1996/2000 1/1 [==============================] - 0s 24ms/step - loss: 0.3370 Epoch 1997/2000 1/1 [==============================] - 0s 26ms/step - loss: 0.3370 Epoch 1998/2000 1/1 [==============================] - 0s 24ms/step - loss: 0.3370 Epoch 1999/2000 1/1 [==============================] - 0s 25ms/step - loss: 0.3369 Epoch 2000/2000 1/1 [==============================] - 0s 24ms/step - loss: 0.3369
可以看到,整个神经网络的训练过程持续稳定的提升,一直到近 2000 个
Epoch
的时候loss
损失还在继续下降,可见继续训练还能进一步提升拟合效果,更好的逼近真实数据。按照管理,我们画个图来观察一下效果:# 5 画图 plt.scatter(xdata, ydata) plt.plot(t_x, y_pred, 'r-', lw=5) plt.show()
如图 8 所示,训练好的神经网络预测结果和真实数据对比几乎是一致的,但是,对比之前 4PL 和多项式的函数拟合,整体效果并不像函数的曲线一样平滑,整体呈现过拟合的状态。
print(t_x.reshape(-1, 1)) print(model.predict(t_x.reshape(-1, 1))) # 输出:预测输出: [[ 0.] [ 500.] [ 1000.] [ 1500.] [ 2500.] [ 6000.] [ 8000.] [10000.] [12000.]] print(t_y.reshape(-1,1)) # 输出: [[ 0. ] [-1.7345996] [ 90. ] [90.01992] [160. ] [159.91837] [210. ] [210.06012] [260. ] [260.01797] [347.5] [347.43182] [357.5] [357.57867] [367.5] [367.53287] [377.5]] [377.4857]
可以看到,对比真实数据神经网络预测的结果是非常精确的,误差比使用多项式拟合的效果好非常多。
至此,我们使用了 4PL、多项式以及神经网络三种可微编程的方法,对弹性阻尼函数进行拟合,逼近真实数据的效果虽然有一定差异,但总体都拟合的比较好。同神经网络的方法不同,4PL 和多项式拟合可以直接提供公式和参数,我们可以方便的将其应用在程序的算法中。而对于神经网络,则需要把神经网络在浏览器里跑起来,下面就介绍一下这个过程。
浏览器执行示例
为了能够把神经网络在浏览器里跑起来,并让 JavaScript 可以调用,首先需要将神经网络存储成文件。
model.save('saved_model/w4model')
存储到目录后会得到两个文件:
keras_metadata.pb
和saved_model.pb
以及两个目录:assets
和variables
。这里使用的是 TensorFlow 的tf_saved_model
,下面转换模型给 TensorFlow.js 在浏览器使用的时候会用到这个参数。 为了能够让模型在浏览器运行,先安装pip install tensorflowjs
用tensorflowjs_converter
命令行转换工具进行模型的转换:tensorflowjs_converter --input_format=tf_saved_model \ --output_node_names="w4model" \ --saved_model_tags=serve ./saved_model/w4model ./web_model
这里
--input_format
参数tf_saved_model
对应之前模型存储使用的方法,要注意不同方法保存的文件格式和结构的不同相互不兼容。--output_node_names
是模型的名称,--saved_model_tags
中tag
是用来区别不同的MetaGraphDef
,这是在加载模型所需要的参数其默认值是serve
。通过
tensorflowjs_converter
进行模型转换后,我们在web_model
文件夹里会看到group1-shard1of1.bin
以及model.json
两个文件。这两个文件中,以.json
后缀结尾的文件是模型定义文件,以.bin
后缀结尾的文件是模型的权重文件。您可以把模型定义文件看做 4PL 和多项式的函数,把模型权重文件看做函数的参数。通过 npm 初始化一个 node.js 项目,然后在
package.json
配置文件里加入:"dependencies": { "@tensorflow/tfjs": "^3.18.0", "@tensorflow/tfjs-converter": "^3.18.0" },
将依赖引入到程序文件中,这里要注意
loadGraphModel
是我们从@tensorflow/tfjs-converter
依赖中导入的,虽然@tensorflow/tfjs
提供tf.loadGraphModel()
方法加载模型,但是这个方法只适用于 TensorFlow.js 中保存的模型,我们通过 Python 里model.save()
方法保存,并用 Converter 转换的模型,必须用tfjs-converter
依赖包中提供的loadGraphModel
方法进行加载。import * as tf from "@tensorflow/tfjs"; import { loadGraphModel } from "@tensorflow/tfjs-converter"; window.onload = async () => { const resultElement = document.getElementById("result"); const MODEL_URL = "model.json"; console.time("Loading of model"); const model = await loadGraphModel(MODEL_URL); console.timeEnd("Loading of model"); const test_data = tf.tensor([ [0.0], [500.0], [1000.0], [1500.0], [2500.0], [6000.0], [8000.0], [10000.0], [12000.0], ]); tf.print(test_data); console.time("Loading of model"); let outputs = model.execute(test_data); console.timeEnd("execute:"); tf.print(outputs); resultElement.innerText = outputs.toString(); };
这里需要注意的是,由于我们的模型在预测时使用的是张量作为输入,因此,需要用
tf.tensor()
方法来返回经过包装的张量作为模型的输入。运行程序后,我们可以从浏览器开发者工具的控制台看到打印的调试信息:[Violation] 'load' handler took 340ms index.js:12 Loading of model: 67.19482421875 ms print.ts:34 Tensor [[0 ], [500 ], [1000 ], [1500 ], [2500 ], [6000 ], [8000 ], [10000], [12000]] index.js:28 execute: 257.47607421875 ms print.ts:34 Tensor [[-1.7345995 ], [90.0198822 ], [159.9183655], [210.0600586], [260.0179443], [347.4320068], [357.5788269], [367.5332947], [377.4856262]]
我们会看到控制台输出
webgl
代表 TensorFlow.js 成功启用了 WebGL 的加速能力,在其加速之下,我们在预测的时候速度会从 257ms 大幅度提升到 131ms,多次预测时间由于权重和计算图已经加载到显存中速度会更快,达到 78ms 左右。当然,对于动画来说这种耗时还是有点多,您可以回顾本书端智能的部分,使用相关的工具和方法对模型进行压缩和优化,只需要在模型输出的时候选择 TensorFlow.js,或者用 TensorFlow 提供的工具把优化后的模型转成 TensorFlow.js 的模型即可复用这个示例中的代码。最后,让我们再次回到弹性阻尼函数在 JavaScript 里的定义:
damping(x, max) { let y = Math.abs(x); y = 0.821 * max / (1 + 4527.779 / Math.pow(y, 1.153)); return Math.round(x < 0 ? -y : y); },
把这里的
y
变成模型预测的结果:y = model.execute(tf.tensor([[x],]));
即可用模型来拟合弹性阻尼函数并返回计算结果。 您可能会问:既然我有 4PL、多项式拟合等方法,为什么要用神经网络呢?是的,如果您要解决的问题简单到足以用函数拟合的方式解决,同时,您也知道应该用 4PL 还是多项式异或是其它函数进行拟合来逼近真实情况。满足这两个前提条件,当然可以用函数拟合的方式直接找到对应的函数和参数来解决问题。但是,现实中我们要解决的问题往往会更加的复杂,比如我们想做个语音输入交互能力,就需要对语音进行音频信号解析、分析、处理、语义识别、转文本等等过程,每个环节中要解决的问题都无法简单用一个函数和单变量多参数方式去描述和拟合变量间的关系。因此,我们可以把复杂的问题用神经网络去拟合,让神经网络通过神经元、Edge、权重、参数等以自动微分的能力去寻找答案。 最后,需要指出的是拟合毕竟是一种近似,无法揭示问题本质是什么?这就是大家经常质疑机器学习可解释性的根源所在。试想一下,如果我们程序的弹性阻尼算法是根据物理学定义的公式去编写的,那这个算法就具备了可解释性。具备了可解释性,我就能够揭示每个变量的意义以及变量之间的关系,从而使程序和算法具备更精确、细致的处理能力。而函数拟合或神经网络拟合的可微编程中,机器学习到了什么?我们很难给出答案,只能通过反复实验来推定函数或神经网络能够根据输入估计出正确的输出。因此,我们不能遇到问题就一股脑的使用可微编程去解决,在解释性方面有强诉求的场景中,尽量还是要从问题本身出发找出解决的途径。原文链接
本文为阿里云原创内容,未经允许不得转载。