一、什么是“黑匣子”
在学习人工智能知识的过程中,我们经常会听到有人说,神经网络就是个“黑盒”或者“黑匣子”,这是什么意思呢?实际上,神经网络的“黑匣子”属性(Black Box Property)是指神经网络作为一个整体系统在处理输入和输出时的不透明性。简单来说,就是我们并不知道给定输入之后,神经网络的输出为什么是abc而不是cba,这与决策树之类的白盒模型形成了鲜明的对比(在白盒模型中,我们能够非常准确地解释模型每一步决策的决定因素)。换句话来说,就是神经网络有着较差的可解释性,这个属性主要体现在以下几个方面:
-
网络复杂性: 神经网络,尤其是如今的大模型,包含大量的层和参数(上亿量级的参数已经是家常便饭了),这使得我们很难弄清楚每个神经元和层是如何贡献于最终的输出的。
-
数据驱动: 神经网络的训练过程高度依赖于数据,网络的行为和输出主要由训练数据决定(给什么学什么),而不是由明确的规则或逻辑决定。
-
模型内部的不可见性: 在神经网络中,输入数据如何通过网络的层被转换为输出的过程是不可见的,我们无法直接观察到网络内部的状态。即便我们能够打印出每一层的参数、数据值,我们也无法解释清楚每个数值的取值原因。
当然,实践证明这种“黑匣子”属性也使得神经网络变得比白盒模型要更为强大。毕竟很多非线性的、隐含的数据规律,单纯通过定义规则是很难发现的,而激活函数的非线性以及反向传播机制等却使得神经网络能够自主学习数据规律,即便在面对未见过的数据时也能做出合理的预测。
二、如何提高神经网络的可解释性
这里,我们介绍近些年来有些名堂的一个概念——物理信息神经网络(Physics-Informed Neural Network,PINN)。PINN在与物理规律相关的深度学习建模任务中发挥了重要作用,是提高神经网络可解释性、提高模型性能的一种有效手段。
PINN通过将物理定律(一般是偏微分方程的形式)嵌入到神经网络的损失函数中,指导神经网络的学习过程朝着与底层物理原理更一致的解决方案发展。这么做的好处是,即便训练的数据量很少,或者数据质量不高,由于有物理规律的制约,神经网络的预测结果也能够符合特定规律,从而减少了输出的随机性和不合理性,提高了整个模型结构以及输出结果的可解释程度。这里,推荐阅读《Physics-informed neural networks: A deep learning framework for solving forward and inverse problems involving nonlinear partial differential equations》这篇论文,文章非常详细地介绍了如何构建一个PINN,且提出了正问题、反问题两种策略:正问题是基于已有的物理规律来控制神经网络的训练过程;而反问题则是基于目前的数据规律推导出潜在的物理关系。此外,作者也很慷慨地给出了可复现的论文源码(Github)。
关于PINN的论文中,物理公式的推导或许很令人头疼,至少对于上一次学物理还是在高中的笔者来说是这样的,而当我们扒开源码来看就很清晰了。例如,作者在论文中用薛定谔方程式作为其中的一个例子。
而相应的源码长这样:
class PhysicsInformedNN:# Initialize the classdef __init__(self, x0, u0, v0, tb, X_f, layers, lb, ub):X0 = np.concatenate((x0, 0*x0), 1) # (x0, 0)X_lb = np.concatenate((0*tb + lb[0], tb), 1) # (lb[0], tb)X_ub = np.concatenate((0*tb + ub[0], tb), 1) # (ub[0], tb)self.lb = lbself.ub = ubself.x0 = X0[:,0:1]self.t0 = X0[:,1:2]self.x_lb = X_lb[:,0:1]self.t_lb = X_lb[:,1:2]self.x_ub = X_ub[:,0:1]self.t_ub = X_ub[:,1:2]self.x_f = X_f[:,0:1]self.t_f = X_f[:,1:2]self.u0 = u0self.v0 = v0# Initialize NNsself.layers = layersself.weights, self.biases = self.initialize_NN(layers)# tf Placeholders self.x0_tf = tf.placeholder(tf.float32, shape=[None, self.x0.shape[1]])self.t0_tf = tf.placeholder(tf.float32, shape=[None, self.t0.shape[1]])self.u0_tf = tf.placeholder(tf.float32, shape=[None, self.u0.shape[1]])self.v0_tf = tf.placeholder(tf.float32, shape=[None, self.v0.shape[1]])self.x_lb_tf = tf.placeholder(tf.float32, shape=[None, self.x_lb.shape[1]])self.t_lb_tf = tf.placeholder(tf.float32, shape=[None, self.t_lb.shape[1]])self.x_ub_tf = tf.placeholder(tf.float32, shape=[None, self.x_ub.shape[1]])self.t_ub_tf = tf.placeholder(tf.float32, shape=[None, self.t_ub.shape[1]])self.x_f_tf = tf.placeholder(tf.float32, shape=[None, self.x_f.shape[1]])self.t_f_tf = tf.placeholder(tf.float32, shape=[None, self.t_f.shape[1]])# tf Graphsself.u0_pred, self.v0_pred, _ , _ = self.net_uv(self.x0_tf, self.t0_tf)self.u_lb_pred, self.v_lb_pred, self.u_x_lb_pred, self.v_x_lb_pred = self.net_uv(self.x_lb_tf, self.t_lb_tf)self.u_ub_pred, self.v_ub_pred, self.u_x_ub_pred, self.v_x_ub_pred = self.net_uv(self.x_ub_tf, self.t_ub_tf)self.f_u_pred, self.f_v_pred = self.net_f_uv(self.x_f_tf, self.t_f_tf)# Lossself.loss = tf.reduce_mean(tf.square(self.u0_tf - self.u0_pred)) + \tf.reduce_mean(tf.square(self.v0_tf - self.v0_pred)) + \tf.reduce_mean(tf.square(self.u_lb_pred - self.u_ub_pred)) + \tf.reduce_mean(tf.square(self.v_lb_pred - self.v_ub_pred)) + \tf.reduce_mean(tf.square(self.u_x_lb_pred - self.u_x_ub_pred)) + \tf.reduce_mean(tf.square(self.v_x_lb_pred - self.v_x_ub_pred)) + \tf.reduce_mean(tf.square(self.f_u_pred)) + \tf.reduce_mean(tf.square(self.f_v_pred))# Optimizersself.optimizer = tf.contrib.opt.ScipyOptimizerInterface(self.loss, method = 'L-BFGS-B', options = {'maxiter': 50000,'maxfun': 50000,'maxcor': 50,'maxls': 50,'ftol' : 1.0 * np.finfo(float).eps})self.optimizer_Adam = tf.train.AdamOptimizer()self.train_op_Adam = self.optimizer_Adam.minimize(self.loss)# tf sessionself.sess = tf.Session(config=tf.ConfigProto(allow_soft_placement=True,log_device_placement=True))init = tf.global_variables_initializer()self.sess.run(init)def initialize_NN(self, layers): weights = []biases = []num_layers = len(layers) for l in range(0,num_layers-1):W = self.xavier_init(size=[layers[l], layers[l+1]])b = tf.Variable(tf.zeros([1,layers[l+1]], dtype=tf.float32), dtype=tf.float32)weights.append(W)biases.append(b) return weights, biasesdef xavier_init(self, size):in_dim = size[0]out_dim = size[1] xavier_stddev = np.sqrt(2/(in_dim + out_dim))return tf.Variable(tf.truncated_normal([in_dim, out_dim], stddev=xavier_stddev), dtype=tf.float32)def neural_net(self, X, weights, biases):num_layers = len(weights) + 1H = 2.0*(X - self.lb)/(self.ub - self.lb) - 1.0for l in range(0,num_layers-2):W = weights[l]b = biases[l]H = tf.tanh(tf.add(tf.matmul(H, W), b))W = weights[-1]b = biases[-1]Y = tf.add(tf.matmul(H, W), b)return Ydef net_uv(self, x, t):X = tf.concat([x,t],1)uv = self.neural_net(X, self.weights, self.biases)u = uv[:,0:1]v = uv[:,1:2]u_x = tf.gradients(u, x)[0]v_x = tf.gradients(v, x)[0]return u, v, u_x, v_xdef net_f_uv(self, x, t):u, v, u_x, v_x = self.net_uv(x,t)u_t = tf.gradients(u, t)[0]u_xx = tf.gradients(u_x, x)[0]v_t = tf.gradients(v, t)[0]v_xx = tf.gradients(v_x, x)[0]f_u = u_t + 0.5*v_xx + (u**2 + v**2)*vf_v = v_t - 0.5*u_xx - (u**2 + v**2)*u return f_u, f_vdef callback(self, loss):print('Loss:', loss)def train(self, nIter):tf_dict = {self.x0_tf: self.x0, self.t0_tf: self.t0,self.u0_tf: self.u0, self.v0_tf: self.v0,self.x_lb_tf: self.x_lb, self.t_lb_tf: self.t_lb,self.x_ub_tf: self.x_ub, self.t_ub_tf: self.t_ub,self.x_f_tf: self.x_f, self.t_f_tf: self.t_f}start_time = time.time()for it in range(nIter):self.sess.run(self.train_op_Adam, tf_dict)# Printif it % 10 == 0:elapsed = time.time() - start_timeloss_value = self.sess.run(self.loss, tf_dict)print('It: %d, Loss: %.3e, Time: %.2f' % (it, loss_value, elapsed))start_time = time.time()self.optimizer.minimize(self.sess, feed_dict = tf_dict, fetches = [self.loss], loss_callback = self.callback) def predict(self, X_star):tf_dict = {self.x0_tf: X_star[:,0:1], self.t0_tf: X_star[:,1:2]}u_star = self.sess.run(self.u0_pred, tf_dict) v_star = self.sess.run(self.v0_pred, tf_dict) tf_dict = {self.x_f_tf: X_star[:,0:1], self.t_f_tf: X_star[:,1:2]}f_u_star = self.sess.run(self.f_u_pred, tf_dict)f_v_star = self.sess.run(self.f_v_pred, tf_dict)return u_star, v_star, f_u_star, f_v_star
关键就在于如何通过一步步求偏导最终得到下面倒数第二行和第三行的结果。其中,f_u代表薛定谔方程的实部表达式,而f_v则是薛定谔方程的虚部表达式:
def net_f_uv(self, x, t):u, v, u_x, v_x = self.net_uv(x,t)u_t = tf.gradients(u, t)[0]u_xx = tf.gradients(u_x, x)[0]v_t = tf.gradients(v, t)[0]v_xx = tf.gradients(v_x, x)[0]f_u = u_t + 0.5*v_xx + (u**2 + v**2)*vf_v = v_t - 0.5*u_xx - (u**2 + v**2)*u return f_u, f_v
三、总结
PINN为神经网络可解释性做出了重要贡献,此外,也有不少研究致力于将神经网络内部的计算过程可视化出来,例如有学者可视化了CNN每一层的特征向量结果,从而一定程度上解释了CNN在处理图像的过程中是如何提取特征的。
然而,我们在很多时候其实并不会特别关注模型的可解释性,只要神经网络足够稳定,具备强鲁棒性,那么不管黑猫白猫,能抓到老鼠就是好猫。PINN除了能够提高可解释性,更重要的是它约束了神经网络的输出要符合物理规律,从而提高模型的鲁棒性。在与物理学强相关的领域,PINN也是个很不错的选择。