tensorflow.js示例笔记 - boston-housing

多元回归,比较不同的房价预测模型。

index.html

<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>Multivariate Regression</title>
        <link rel="stylesheet" href="../shared/tfjs-examples.css"/>
        <style>
            .negativeWeight {
                color: #cc0000;
            }

            .positiveWeight {
                color: #00aa00;
            }

            #buttons {
                margin-top: 20px;
                padding: 5px;
            }

            #oneHidden {
                border-left: 1px solid #ededed;
                border-right: 1px solid #ededed;
            }

            #linear,
            #oneHidden,
            #twoHidden {
                padding: 5px;
            }
        </style>
    </head>
    <body>
        <div class="tfjs-example-container centered-container">
            <section class="title-area">
                <h1>Multivariate Regression</h1>
                <p class="subtitle">Compare different models for housing price prediction.</p>
            </section>
            <section>
                <p class="section-head">Description</p>
                <p>
                    This example shows you how to perform regression with more than one input feature using the
                    <a href="https://www.cs.toronto.edu/~delve/data/boston/bostonDetail.html">Boston Housing Dataset</a>,
                    which is a famous dataset derived from information collected by the U.S. Census Service concerning housing
                    in the area of Boston Massachusetts.
                </p>
                <p>
                    It allows you to compare the performance of 3 different models for predicting the house prices. When
                    training the linear model, it will also display the largest 5 weights (by absolute value) of the model and
                    the feature associated with each of those weights.
                </p>
            </section>
            <section>
                <p class="section-head">Status</p>
                <p id="status">Loading data...</p>
                <p id="baselineStatus">Baseline not computed...</p>
            </section>
            <section>
                <p class="section-head">Super Parameters</p>
                <p id="super-parameters"></p>
            </section>
            <section>
                <p class="section-head">Training Progress</p>
                <div class="with-cols">
                    <div id="linear">
                        <div class="chart"></div>
                        <div class="status"></div>
                        <div id="modelInspectionOutput">
                            <p id="inspectionHeadline"></p>
                            <table id="myTable"></table>
                        </div>
                    </div>
                    <div id="oneHidden">
                        <div class="chart"></div>
                        <div class="status"></div>
                    </div>
                    <div id="twoHidden">
                        <div class="chart"></div>
                        <div class="status"></div>
                    </div>
                </div>
                <div id="buttons">
                    <div class="with-cols">
                        <button id="simple-mlr">Train Linear Regressor</button>
                        <button id="nn-mlr-1hidden">Train Neural Network Regressor (1 hidden layer)</button>
                        <button id="nn-mlr-2hidden">Train Neural Network Regressor (2 hidden layers)</button>
                    </div>
                </div>
            </section>
        </div>
        <script type="module" src="index.js"></script>
    </body>
</html>

index.js

import * as tf from '@tensorflow/tfjs';
import * as tfvis from '@tensorflow/tfjs-vis';
import * as ui from './ui';
import * as normalization from './normalization';
import {BostonHousingDataset, featureDescriptions} from './data';

// Some hyperparameters for model training.
// 模型训练的超参数,通常情况下超参数是我们能直接调整的参数,而权重参数是模型在训练过程中通过反向
// 传播来不断优化并自动调整的。
// 除了下面列出的常量,模型的层的单元数、内核初始化函数和激活函数等也是模型的超参数,他们在后面的
// 内容中会提到。一旦为模型选择了超参数,它们就不会在训练过程中改变。他们通常确定参数的数量和大小
// (例如units),参数的初始值(例如kernelInitializer)以及在训练期间如何更新它们(例如考虑
// 传递Model.compile的优化器字段)。它们的级别高于权重参数,故被称为超参数(hyper,而不是sub)。
//
// 超参数通常情况下包含关乎模型体系结构的人为可调参数:
// 1. 模型中密集层的数量、kernel初始化类型。
// 2. 是否使用权重正则化,如果是,正则化因子是多少。
// 3. 是否包括任何dropout层,如果包含,dropout率是多少。
// 与模型本身的体系结构无关,属于模型训练过程的配置,但会影响训练的结果,因此被视为超参数的还有:
// 4. 用于训练的优化器的类型的选择,优化器的学习率。
// 5. 需要多少次来训练模型。
// 6. 是否应随着训练的进行逐渐降低优化器的学习率,如果是,应以什么速率降低。
// 7. 训练的批量大小。
// 对于由更多样化类型的层,还有更潜在的可调谐的超参数。因此,为什么即使是简单的深度学习模型也可能具有
// 数十个可调超参数。 选择良好的超参数值的过程称为超参数优化或超参数调整。超参数优化的目标是找到一组
// 参数,使训练后的验证损失最小。
export const NUM_EPOCHS = 200;     // 训练迭代周期数。
export const BATCH_SIZE = 40;      // 单个批的大小。
export const LEARNING_RATE = 0.01; // 优化器学习速率。

const bostonData = new BostonHousingDataset();
const tensors = {};

// Convert loaded data into tensors and creates normalized versions of the features.
export function arraysToTensors() {
    tensors.rawTrainFeatures = tf.tensor2d(bostonData.trainFeatures);
    tensors.trainTarget = tf.tensor2d(bostonData.trainTarget);
    tensors.rawTestFeatures = tf.tensor2d(bostonData.testFeatures);
    tensors.testTarget = tf.tensor2d(bostonData.testTarget);

    // 计算数据数组中每列的平均值和标准差。
    const {dataMean, dataStdDeviation} = normalization.determineMeanAndStddev(tensors.rawTrainFeatures);

    // 对数据的平均值和标准差进行归一化处理,实现对原始数据的缩放。
    tensors.trainFeatures = normalization.normalizeTensor(tensors.rawTrainFeatures, dataMean, dataStdDeviation);
    tensors.testFeatures = normalization.normalizeTensor(tensors.rawTestFeatures, dataMean, dataStdDeviation);
    // ***实测用这套数据在不做归一化的情况下直接训练模型,梯度爆炸/消失了(各种NaN),验证集损耗震动巨大。

    // 对归一化的简单解释:
    // 1. 归一化后加快了梯度下降求最优解的速度。如果机器学习模型使用梯度下降法求最优解时,并存在有异常的数据时,归一
    // 化往往非常有必要,否则梯度很难收敛甚至不能收敛。
    // 2. 归一化有可能提高精度。一些分类器需要计算样本之间的距离(如欧氏距离),例如KNN。如果一个特征值域范围非常大,
    // 那么距离计算就主要取决于这个特征,从而与实际情况相悖(比如这时实际情况是值域范围小的特征更重要)。并能防止梯度
    // 爆炸、消失等异常情况。
    // 3. 概率模型(树形模型)通常不需要归一化。因为它们不关心变量的值,而是关心变量的分布和变量之间的条件概率,如决
    // 策树、RF。
}

/**
 * Builds and returns Linear Regression Model.
 *
 * @returns {tf.Sequential}: The linear regression model.
 */
export function linearRegressionModel() {
    // 普通的线性回归模型。

    const model = tf.sequential();

    // 层有关的配置都是针对keras的配置(在tf中是原生的keras的API或其封装,在tfjs中是对其实现的模拟)。
    // 一个输出层,单个神经元,也就是输出预测的房价。
    model.add(tf.layers.dense({
        inputShape: [bostonData.numFeatures], // => [12]
        units: 1
    }));

    // 打印模型概述信息到控制台。
    model.summary();
    return model;
}

/**
 * Builds and returns Multi Layer Perceptron Regression Model
 * with 1 hidden layer, each with 10 units activated by sigmoid.
 *
 * @returns {tf.Sequential}: The multi layer perceptron regression model.
 */
export function multiLayerPerceptronRegressionModel1Hidden() {
    // 多层感知器回归模型(1个隐藏层)。
    // 感知器模型结构:1个输入层,1个到多个隐藏层(进一步抽象输入层的数据),1个输出层。
    // ***感知器本质上是对神经元最基本概念的模拟,其实并未包含有太多的网络概念,它的表现
    // 更倾向于自动做决策的机器:
    // 比如说你要决定今天出不出去看电影,你可能会考虑下面3个因素:
    // 1. 女朋友在不在。
    // 2. 电影好不好看。
    // 3. 今天有没有工作。
    // 每个人对三个因素的权重都不同,有的人看重女朋友,有的人看重工作,所以权重就不等。某个
    // 考虑的因素都可以对应隐藏层上一个神经元,甚至一个因素还可以细分为子因素,每一个感知器
    // 就是对某个因素做决测的机器。最后每个人根据自己的权重做出0或1,去或不去,或者说是或
    // 不是的决策。这时就需要把三个要素按照它们需要的权重加和在一起,在把这个分数送到一个叫
    // sigmoid的激活函数里得到去或不去的决定,这个sigmoid激活函数会在下面的内容中会提到,
    // 他在这里的作用类似于门控函数。

    const model = tf.sequential();

    // 设置1个隐藏层,该层神经单元为50个(这个数量不需要匹配数据集的形状),每个神
    // 经单元都可以看做是一个节点,也就是一个感知器,每个感知器负责某个特征、子特征
    // 的鉴别。
    model.add(tf.layers.dense({
        inputShape: [bostonData.numFeatures],
        // units代表该层的神经元个数或输出维度。解释为神经元个数为了方便计算参数量,解释为输
        // 出维度为了方便计算维度。
        // 隐藏层神经元数量通常可以由以下几个原则来确定:
        // 1. 应在输入层的大小和输出层的大小之间。
        // 2. 隐藏神经元的数量应为输入层大小的2/3加上输出层大小的2/3。
        // 3. 隐藏神经元的数量应小于输入层大小的两倍。
        // 神经元数量过少会导致欠拟合,过多会导致过拟合。当神经网络具有过多的节点(过多的信息处
        // 理能力)时,训练集中包含的有限信息量不足以训练隐藏层中的所有神经元,因此就会导致过拟合。
        // 即使训练数据包含的信息量足够,隐藏层中过多的神经元会增加训练时间,从而难以达到预期的效
        // 果。显然,选择一个合适的隐藏层神经元数量是至关重要的。
        // 要找到合适的神经元数量,通常是通过经验公式先确定一个数量,然后再训练模型,从训练图表
        // 上检查是否有过拟合或者欠拟合,再调整参数,直至满意为止。
        units: 50,
        // 激活函数是连接感知机和神经网络的桥梁。
        //
        // sigmoid函数是一条平滑的曲线,在Y轴附近,可以看做是一个近似直线的线性函数,近线性的
        // 这部分可用于学习数据的线性特征。其输出随着输入发生连续性的变化,其平滑性对神经网络的
        // 学习具有重要意义。处理回归类问题,如果数据集经过了归一化处理,通常会采用该函数。
        //
        // 从生物学领域来讲,sigmoid函数是一个常见的S型函数,也称为S型生长曲线。在信息科学中,
        // 由于其单增以及反函数单增等性质,Sigmoid函数常被用作神经网络的激活函数,将变量映射到
        // 0和1之间。
        //
        // 引入激活函数的目的是在模型中引入非线性。如果没有激活函数那么无论你的神经网络有多少层,
        // 最终都是一个线性映射,也就是矩阵的相乘而已,无论叠加了多少层都是一样的线性结果,线性
        // 部件之间再怎么组合结果都是线性的,和单个线性部件没有区别,那么网络的预测逼近真实值的
        // 能力就相当有限,线性函数的复杂性有限,处理复杂函数映射的能力弱,单纯的线性映射无法解
        // 决线性不可分问题,在处理复杂表单、视频、图像上乏力。基于这些的原因,我们通过引入非线
        // 性函数作为激活函数,这样深层神经网络表达能力就更加强。
        //
        // 即使激活函数本身是非线性的,但其内部往往有一部分属于或近似于线性,可以利用这部分来学
        // 习响应的权重的和偏差。
        //
        // 实质上,神经网络就是级联函数。神经网络的每一层都可以看作是一个函数,而各层的堆叠相当
        // 于将这些函数级联形成更复杂的函数,即神经网络本身。这就是为什么包括非线性激活函数会增
        // 加模型的输入输出关系范围的原因。
        //
        // 将激活函数注释掉以后,再训练,会发现损耗和普通的线性回归模型差不多,而且震动更大。换
        // 句话说,没有激活函数的两层模型的性能与单层线性回归的性能大致相同。因此这在构建多层神
        // 经网络时,请确保在隐藏层中包括非线性激活。如果不这样做的结果便是浪费计算资源和时间,
        // 而且也使得数值不稳定。通常多层神经网络内,必须含有级联的线性函数和非线性函数,有助于
        // 表示能力的增强,这意味着模型容量的增大,预测精度的提高。
        activation: 'sigmoid',
        // activation: 'relu',
        // 初始化器定义了设置keras各层权重w的随机初始值的方法。
        // lecunNormal是一种根据输入大小生成内核初始值的特殊方法。它与默认的内核初始化程序
        // (glorotNormal)不同。glorotNormal使用输入和输出的大小。那么问题是:为什么要使
        // 用此自定义内核初始化而不是默认的?为什么要使用50个单位(而不是30个单位)?答案是:
        // 这些是通过反复尝试各种参数组合,以求获得最佳模型质量而选取的。
        kernelInitializer: 'leCunNormal'
        // units、activation和kernelInitializer也是我们能够调整和优化的超参数。
    }));

    // 隐藏层在这里的作用就是对输入特征进行再抽象,最终的目的就是为了更好的线性划分不同类型的数据。

    // 一个输出层,单个神经元,也就是输出预测的房价。
    model.add(tf.layers.dense({
        units: 1
    }));

    // 打印模型概述信息到控制台。
    model.summary();
    return model;
}

/**
 * Builds and returns Multi Layer Perceptron Regression Model
 * with 2 hidden layers, each with 10 units activated by sigmoid.
 *
 * @returns {tf.Sequential} The multi layer perceptron regression model.
 */
export function multiLayerPerceptronRegressionModel2Hidden() {
    // 多层感知器回归模型(2个隐藏层)。

    const model = tf.sequential();

    // 设置两个隐藏层,神经单元为50个(这个数量不需要匹配数据集的形状)。
    model.add(tf.layers.dense({
        inputShape: [bostonData.numFeatures],
        units: 50,
        activation: 'sigmoid',
        // activation: 'relu',
        kernelInitializer: 'leCunNormal'
    }));

    model.add(tf.layers.dense({
        units: 50,
        activation: 'sigmoid',
        // activation: 'relu',
        kernelInitializer: 'leCunNormal'
    }));

    // 相比上一个模型来说,又多增加了一个隐藏层,对输入特征进行了更多层次的抽象。
    // 是不是隐藏层约多就越好呢,可以特征划分的更清楚?理论上是这样的,但实际这样会带来两个问题。
    // 1. 层数越多参数也会爆炸式地增多。
    // 2. 在少量层数就能完成特征划分的时候,在此基础上再增加的层已无实际的意义,对划分能力已无提升。
    //
    // 没有隐藏层:仅能够表示线性可分函数或决策(比如第一个模型)。
    // 隐藏层数=1:可以拟合任何“包含从一个有限空间到另一个有限空间的连续映射”的函数。
    // 隐藏层数=2:搭配适当的激活函数可以表示任意精度的任意决策边界,并且可以拟合任何精度的任何平滑映射。
    // 隐藏层数>2:多出来的隐藏层可以学习复杂的描述(某种自动特征工程)。
    //
    // 在多隐藏层的情况下,越靠后的隐藏层,其抽象度越高,在它前面的层在抽象上会比它低阶。比如我们这里有
    // 一个含有多隐藏层的深度神经网络,他是用来分辨一个图像是否是一个人脸图像,简单地来讲,我们可以抽象
    // 出第一层:有头发吗,有左右两个眼睛吗,有左右两个耳朵吗,有鼻子吗,有嘴巴吗,比如在眼睛这个上面
    // 我们还还可以细分:有左眼睛吗,有右眼睛吗,然后在此基础上再细分:眼睛有睫毛吗,有眼球吗,有虹膜吗。
    // 就这形成了多个隐藏层,每个层的决策点也是一个单独的神经元,负责输出某一个特征点或子特征点的决策。
    // 这里和上面讲到的unit神经单元的内容是一致的。
    //
    // 因此,对于一般简单的数据集,一两层隐藏层通常就足够了。但对于涉及时间序列或计算机视觉的复杂数据集,
    // 则需要额外增加层数。单层神经网络只能用于表示线性分离函数,也就是非常简单的问题,比如分类问题中的
    // 两个类可以用一条直线整齐地分开。层数越深,理论上拟合函数的能力增强,效果按理说会更好,但是实际上
    // 更深的层数可能会带来过拟合的问题,同时也会增加训练难度,使模型难以收敛。
    //
    // 对于本模型来说,再加1个隐藏层到3个隐藏层,实际已经看不到任何提升了,损耗的震荡更大,收敛更困难。
    //
    // ***隐藏层对于分类问题来说很容易理解,其有助于提升分类的准确性和速度。对于本项目的回归问题来说也
    // 容易理解:通过更抽象的特征来使预测结果更接近真实的值。
    //
    // ***通常情况下,我们将隐藏层数量大于2的神经网络叫做深度神经网络,而深度学习,就是使用深层架构(比
    // 如:深度神经网络)的机器学习方法。

    // 一个输出层,单个神经元,也就是输出预测的房价。
    model.add(tf.layers.dense({
        units: 1
    }));

    model.summary();

    return model;
}


/**
 * Describe the current linear weights for a human to read.
 *
 * @param {Array} kernel Array of floats of length 12. One value per feature.
 * @returns {List} List of objects, each with a string feature name, and value feature weight.
 */
export function describeKernelElements(kernel) {
    // 创建线性kernel参数(权重)。

    // 权重不是12个就告警提示。
    tf.util.assert(kernel.length === 12, `kernel must be a array of length 12, got ${kernel.length}`);

    const outList = [];

    // 按顺序匹配出权重名称和对应的值。
    for (let idx = 0; idx < kernel.length; idx++) {
        outList.push({
            description: featureDescriptions[idx],
            value: kernel[idx]
        });
    }
    return outList;
}

// Calculate meanSquaredError.
export function computeBaseline() {
    // 计算平均价格。
    const avgPrice = tensors.trainTarget.mean();
    console.log(`Average price: ${avgPrice.dataSync()}`); // => 22.77

    // 调用dataSync方法的用处是讲数据从GPU中拿到CPU中。
    // 当我们使用GPU时,默认所有的张量操作是非阻塞的、异步执行的。JavaScript的主线程没法立刻获取到某个
    // 张量的完整数据。tfjs提供了两种获取办法,data和dataSync,data是异步化的,返回一个Promise,而
    // dataSync则是同步化的,会阻塞线程,直到张量下载完毕。

    // 计算基线。
    const baseline = tensors.testTarget.sub(avgPrice).square().mean();
    console.log(`Manually calculated baseline loss (meanSquaredError): ${baseline.dataSync()}`); // => 85.58, sqr => 9.25

    // 更新基线信息。
    const baselineMsg = `Manually calculated baseline loss (meanSquaredError) is ${baseline.dataSync()[0].toFixed(2)}`;
    ui.updateBaselineStatus(baselineMsg);
}

/**
 * Compiles `model` and trains it using the train data and runs model against
 * test data. Issues a callback to update the UI after each epoch.
 *
 * @param {tf.Sequential} model Model to be trained.
 * @param {string} modelName Model name
 * @param {boolean} weightsIllustration Whether to print info about the learned weights.
 */
export async function run(model, modelName, weightsIllustration) {
    model.compile({
        // 优化器和优化器的学习率也属于超参数。
        // 优化器用来更新和计算影响模型训练和模型输出的网络参数,使其逼近或达到最优值,从而最小
        // 化(或最大化)损失函数。在深度学习中,几乎所有流行的优化器都基于梯度下降。这意 味着他
        // 们反复估计给定的损失函数L的斜率,并将参数向相反的方向移动(因此向下爬升到一个假设的全
        // 局最小值),以此来不断更新参数。
        // 当学习率LEARNING_RATE为0.01(本案例值)的时候,等同于{optimizer: 'sgd', ...}。
        // 学习率决定了在每步参数更新中,模型参数有多大程度(或多快、多大步长)的调整。学习率需
        // 要在收敛和过火之间权衡。学习率太小,则收敛得慢。学习率太大,则损失会震荡甚至变大。
        // 学习率还会跟优化过程的其他方面相互作用,这个相互作用可能是非线性的。小的batch size
        // 最好搭配小的学习率,因为batch size越小越可能有噪音,这时候就需要小心翼翼地调整参数。
        optimizer: tf.train.sgd(LEARNING_RATE),
        loss: 'meanSquaredError'
    });

    // 图表数据预声明。
    const chartContainer = document.querySelector(`#${modelName} .chart`);
    const trainLogs = [];

    ui.updateStatus('Starting training process...');

    // 计时。
    const startTime = Date.now();

    // 执行训练。
    await model.fit(
        tensors.trainFeatures, // 特征输入。
        tensors.trainTarget, // 与特征样本对应的标签输入。
        {
            batchSize: BATCH_SIZE,
            epochs: NUM_EPOCHS,
            // 训练数据的中最后的20%的数据作为验证数据使用,不会用于训练,不会直接影响到梯度。
            // 通过查看训练集和验证集的损失值随着epoch的变化关系可以看出模型是否过拟合,如果
            // 是可以及时停止训练,然后根据情况调整模型结构和超参数,大大节省时间。
            validationSplit: 0.2,
            callbacks: {
                onEpochEnd: (epoch, logs) => {
                    // 更新epoch状态。
                    ui.updateModelStatus(`Epoch ${epoch + 1} of ${NUM_EPOCHS} completed.`, modelName);

                    // 更新梯度图表。
                    trainLogs.push(logs); // logs => {loss: 23.249059677124023, val_loss: 25.111482620239258}
                    tfvis.show.history(
                        chartContainer,      // 图表根DOM。
                        trainLogs,           // 图表数据数组。
                        ['loss', 'val_loss'] // 图表图例。
                    )

                    if (weightsIllustration) {
                        // 在普通线性模型训练时,需要展示前5的kernel参数(权重)信息。
                        //
                        // 而对于含有隐藏层的模型来说,其内部的权重参数往往不具备人能识别的意义:在我们的实例模型中,
                        // 与普通线性模型相比,含有隐藏层的模型有数百个权重参数,这些权重是创建的高维空间的维,以便
                        // 模型可以学习其中的非线性关系。人类的头脑不是很擅长跟踪此类高维空间中的非线性关系。通常,
                        // 很难用几句话来描述每个隐藏层的参数做什么,或者很难解释它对深度神经网络的最终预测做出哪些
                        // 贡献。当存在多个彼此叠加的隐藏层时,比如我们定义的第三个模型,这种关系会变的更加晦涩和难
                        // 以描述。
                        //
                        // 尽管人们正在努力寻找更好的方法来解释深度神经网络隐藏层的含义,并且某些类型的模型取得了一
                        // 些进展,但深度神经网络与浅层神经网络和某些类型的非神经网络机器学习模型(例如决策树)相比
                        // 很难进行解释。
                        //
                        // ***适用较深的模型替换较浅的模型,实质上是在用可解释性换取更大的模型容量。
                        model
                            .layers[0]
                            .getWeights()[0]
                            .data()  // 异步获取GPU数据。同步方法为dataSync,会阻塞主线程。
                            .then(kernelAsArr => {
                                // 获取权重。
                                const weightsList = describeKernelElements(kernelAsArr);
                                // 更新权重信息。
                                ui.updateWeightDescription(weightsList);
                            });

                        // 在当前数据集下,在普通单层线性回归模型中,按绝对值排名前5的权重为:
                        // 1. School drop-out rate(未接受高中教育的在职男性百分比(失去学率))  // => -4.3455
                        // 2. Distance to commute(波士顿五个就业中心的通勤加权距离)           // => -2.9920
                        // 3. Distance to highway(离高速公路的距离(径向公路通达性指数))      // => 2.8434
                        // 4. Number of rooms per house(某个房屋的平均房间数)              // => 2.2199
                        // 5. Tax rate(每10000美元的税率)                                // => -2.2135
                        //
                        // 权重的值在每次训练中都是在一个范围内随机的,因为我们指定的是sgd随机梯度下降。
                        //
                        // 可以看到各类权重的正负值对房屋价格的影响是符合我们人类的认知的。我们可以简单
                        // 地认为负值是拉低房价的,正值是拉高房价的(但这个分析并不一定成功,看最下方):
                        // 1. 失学率对房价肯定是负影响,失学率越高,房价越低;
                        // 2. 上下班往返的通勤距离越远,房价越低;
                        // 3. 离高速公路的距离越远,房价越高;
                        // 4. 地区房屋的平均房间数月越多,地区的房屋越豪华,房价越高;
                        // 5. 税率越高,房价越低。
                        //
                        // kernel[i]为正,则意味着feature[i]的值越大,预测输出就越大。为负则越小。
                        //
                        // 通过观察UI上的信息输出变化,我们也能发现在整个训练过程中按绝对值排名前五的权重
                        // 并不是一成不变的。不同特征的权重在训练中一直在变化,特别是2-5位权重,变化了不止
                        // 一次,失学率这个权重相对来说比较稳定,收敛快,因为其对房价的影响从直观数据来看
                        // 就确实比其他的特征要大。4和5权重在每次训练中收敛的值不同,变化较大,这个位次上
                        // 有3个左右的权重的绝对值比较接近。
                        //
                        // ***根据上面的结果我们可能会承认该模型已得到房屋的房间数特征与价格输出呈正相关,
                        // 或者房地产的AGE特征(未列出)相对于前五个特征而言,其绝对值的重要性较低。但是这
                        // 种分析可能会失败,原因之一即是两个输入特征之间具有很强的相关性。房价只有犯罪率在
                        // 一定范围内时,才随着其呈负向变化。再考虑一个假设的示例,其中同一特征被两次输入,
                        // 称它们为FEAT1和FEAT2。想象一下,从这两个特征中学到的权重是10和-5。您可能倾向于
                        // 说增加FEAT1会导致更大的输出,而FEAT2则相反。但是,由于特征是等效的,即使权重反
                        // 转,模型也将输出完全相同的值。需要注意的是相关性和因果关系之间的差异。想象一个简单
                        // 的模型,我们希望通过屋顶的潮湿程度来预测外面下雨的程度。如果我们测量了屋顶的湿度,
                        // 我们可能可以预测过去一个小时的降雨量。但是,我们不能将水溅到传感器来模拟要下雨这
                        // 个问题!
                        //
                        // 再来看看排名最后5位的权重:
                        // 12. Age of housing(1940年之前建造的自有住房的部分)              // => 0.2233
                        // 11. Industrial proportion(城镇非零售营业面积(工业面积)的比重)  // => 0.4494
                        // 10. Crime rate(犯罪率)                                      // => -0.7306
                        // 9. Next to river(是否毗邻查尔斯河)                              // => 0.9258
                        // 8. Land zone size(占地超过25000平方英尺的住宅用地比例)          // => 1.4693
                        //
                        // 在排名最低的5个权重中,除了8-10以外,11和12位的两个权重,在每次训练中收敛的损耗都不同,
                        // 并且有时候还是正、负交替,损耗绝对值太低,几乎都没超过0.45的时候。很难确定其对房价预测值
                        // 到底是有积极影响还是有负面影响。
                        //
                        // ***对于每个训练实例,算法将其发送到网络中,并计算每个连续层中每个神经元的输出(这是正向的
                        // 过程,和做预测一样)。然后它会度量网络的输出误差(对比预测值和实际的输出值),然后它会计算
                        // 每个神经元对输出神经元的误差的贡献度。之后它会继续测量这些误差贡献度中有多少是来自于前一个
                        // 隐藏层中的每一个神经元,这个过程一直持续到输入层(也就是第一层),这个步骤会重复很多次直到
                        // 能找到一个最优的权重值和误差。
                        //
                        // 简而言之,对于每个训练实例,首先是做反向传播算法做一次预测(正向过程),然后度量
                        // 误差,然后反向遍历每个层次来度量每个连接的误差贡献度(反向过程),最后再微调每个连接
                        // 的权重来降低误差(梯度下降)。
                    }
                }
            }
        }
    );

    // 计时。
    console.log(`training time elapse: ${Date.now() - startTime} ms`);

    // 用测试数据来评估模型,以查看模型的损耗是否比我们手动计算的meanSquaredError平均平方误差损耗更好。
    ui.updateStatus('Running on test data...');

    const lastTrainLog = trainLogs[trainLogs.length - 1];
    // 最终训练集损耗。
    const trainLoss = lastTrainLog.loss;
    // 最终验证集损耗。
    const valLoss = lastTrainLog.val_loss;

    // 评估模型。
    const result = model.evaluate(
        tensors.testFeatures,
        tensors.testTarget,
        {batchSize: BATCH_SIZE}
    );

    // 测试(评估)集损耗。
    const testLoss = result.dataSync()[0];

    // 以下测试集的损耗值皆有随机性,每次结果不同:
    //
    // 普通的线性回归模型linearRegressionModel模型结果:
    // 最终训练集损耗在20左右,最终验证集损耗在30左右。
    // 测试(评估)集损耗在25左右,这比我们手动计算的基线meanSquaredError平均平方误差85.58要好得多。
    // 特别是取平方根值(开平方)以后,仅仅在5左右,比9.2的基线数据要好。在第10个周期时,出现了拐点。
    // ***损耗虽然不比下面两个模型高,但已能进行较为合理的预测。说明单个稠密层足以解决一些简单的回归类问题。
    //
    // 多层感知器回归模型(含1个隐藏层)multiLayerPerceptronRegressionModel1Hidden模型结果:
    // 最终训练集损耗在7左右,最终验证集损耗在25左右。
    // 测试(评估)集损耗在13左右。已大幅优于上一个模型的结果。在训练过程中,收敛更加迅速,但出现梯度震荡。
    // 在第3个周期时,梯度就已经出现了拐点。
    // 在控制台的summary日志中,该模型参数为普通的线性回归模型的参数量的54倍。summary中出现的null,
    // 表示未确定且可变的大小。第一密集层包含两个系数:形状为[12,50]的kernel和形状为[50]的bias,从
    // 而有 12 * 50 + 50 = 650 个参数。
    //
    // 多层感知器回归模型(含2个隐藏层)multiLayerPerceptronRegressionModel2Hidden模型的结果:
    // 最终训练集损耗在5.5左右,最终验证集损耗在23左右。但我们在这里可以看到验证集损耗比训练集损耗要大,
    // 这表示在训练过程中可能出现了过拟合,关于这点将在后续的项目案例中对处理方法进行介绍。
    // 测试(评估)集损耗在11.5左右。继续优于上一个模型的结果。但在训练过程中,梯度震荡继续增大。
    // 在170个周期以后,训练损耗都还有一定程度的收敛,这点明显优于上面2个模型。
    //
    // 三个模型在训练时长上区别不大,在最新版浏览器下,Intel Iris Pro 1.5GB 核显耗时在13s左右。
    //
    // ***当我们将隐藏层的激活函数从sigmoid切换到relu后,在1个隐藏层模型中我们会发现梯度收敛更加迅速,
    // 在2个隐藏梦模型中,val_loss震动极大,但train_loss尽然能收敛到2左右。有兴趣的可以深入了解下不
    // 同激活函数的倾向性和区别。
    //
    // ***模型的损失侧门那个面说明了模型与参数值网格的拟合程度。由于参数空间的高维性,损失表面通常无法计算,
    // 但可以说明并思考机器学习的工作原理。

    // 更新模型状态文本。
    await ui.updateModelStatus(
        `Final train-set loss: ${trainLoss.toFixed(4)}\n` +
        `Final validation-set loss: ${valLoss.toFixed(4)}\n` +
        `Test-set loss: ${testLoss.toFixed(4)}`,
        modelName
    );
}

document.addEventListener('DOMContentLoaded', async () => {
    // 加载原始数据。
    await bostonData.loadData();
    ui.updateStatus('Data loaded, converting to tensors');
    // 将原始数据的转换为张量(特征数据需要归一化处理)。
    arraysToTensors();
    ui.updateStatus('Data is now available as tensors.\n' + 'Click a train button to begin.');
    // TODO Explain what baseline loss is. How it is being computed in this Instance.
    ui.updateBaselineStatus('Estimating baseline loss');
    // 计算基线。
    computeBaseline();
    // 显示当前的超参数设置。
    ui.updateSuperParameter();
    // 设置UI(模型训练前的最后准备)。
    ui.setup();
}, false);

data.js

// CSV parser.
const Papa = require('papaparse');

// Boston Housing data constants:
const BASE_URL = 'https://storage.googleapis.com/tfjs-examples/multivariate-linear-regression/data/';

// 数据来源于1978年,美国人口普查服务机构对波士顿地区的300多套房的各项数据的统计。
// 数据集包含12+1个特征,最后一个为中位数房价(标签):
// 指数  特征      特征说明                          平均值   值范围(最大值减去最小值)
// 0    CRIM     犯罪率                              3.62      88.9
// 1    ZN         占地超过25000平方英尺的住宅用地比例      11.4      100
// 2    INDUS     城镇非零售营业面积(工业)的比重          11.2      27.3
// 3    CHAS     该地区是否毗邻查尔斯河                  0.0694  1
// 4    NOX         一氧化氮浓度(百万分之一)              0.555      0.49
// 5    RM         每个住宅的平均房间数                  6.28      5.2
// 6    AGE         1940年之前建造的自有住房的部分          68.6      97.1
// 7    DIS         到五个波士顿就业中心的加权距离          3.80      11.0
// 8    RAD         径向公路通达性指数                      9.55      23.0
// 9    TAX         每10000美元的税率                  408      524.0
// 10    PTRATIO     师生比例                              18.5      9.40
// 11    LATAT     未接受高中教育的在职男性百分比          12.7      36.2
// 12    MEDV     拥有住房的中位数价值,单位为$1000US    22.5      45
const TRAIN_FEATURES_FN = 'train-data.csv';
const TRAIN_TARGET_FN = 'train-target.csv'; // Unit is k dollar, 2.8 => $21800.
const TEST_FEATURES_FN = 'test-data.csv';
const TEST_TARGET_FN = 'test-target.csv';

/**
 * Given CSV data returns an array of arrays of numbers.
 *
 * @param {Array<Object>} data: Downloaded data.
 *
 * @returns {Promise.Array<number[]>} Resolves to data with values parsed as floats.
 */
const parseCsv = async (data) => {
    return new Promise(resolve => {
        // Flatten object key-value to simple value array.
        data = data.map((row) => {
            return Object.keys(row).map(key => parseFloat(row[key]));
        });
        resolve(data);
    });
};

/**
 * Downloads and returns the csv.
 *
 * @param {string} filename: Name of file to be loaded.
 *
 * @returns {Promise.Array<number[]>} Resolves to parsed csv data.
 */
export const loadCsv = async (filename) => {
    return new Promise(resolve => {
        const url = `${BASE_URL}${filename}`;

        // console.log(`* Downloading data from: ${url}`);
        Papa.parse(url, {
            download: true,
            header: true,
            complete: (results) => {
                // console.log(filename, results.data);
                resolve(parseCsv(results['data']));
            }
        })
    });
};

// Helper class to handle loading training and test data.
export class BostonHousingDataset {
    constructor() {
        // Arrays to hold the data.
        this.trainFeatures = null;  // Input data.
        this.trainTarget = null;    // Output data.
        this.testFeatures = null;
        this.testTarget = null;
    }

    get numFeatures() {
        // If numFeatures is accessed before the data is loaded, raise an error.
        if (this.trainFeatures == null) {
            throw new Error('\'loadData()\' must be called before numFeatures')
        }
        return this.trainFeatures[0].length;
    }

    // Loads training and test data.
    async loadData() {
        [
            this.trainFeatures,
            this.trainTarget,
            this.testFeatures,
            this.testTarget
        ] = await Promise.all([
            loadCsv(TRAIN_FEATURES_FN),
            loadCsv(TRAIN_TARGET_FN),
            loadCsv(TEST_FEATURES_FN),
            loadCsv(TEST_TARGET_FN)
        ]);

        // 进行shuffle洗牌操作是为了避免数据的顺序对模型训练造成的抖动影响,有利于模型的健壮性。
        // 也可以增强随机性,提高神经网络的泛化性能,避免有规律的数据出现而导致的权重在更新时的梯
        // 度过于极端,进而避免模型出现过度拟合和欠拟合,防止神经网络去拟合了不应该拟合的模式。
        shuffle(this.trainFeatures, this.trainTarget);
        shuffle(this.testFeatures, this.testTarget);
    }
}

export const featureDescriptions = [
    // 下标对应train-data中的前12个特征,也就是应线性模型中的kernel参数(权重)。
    'Crime rate',                   // 0   crim     地区犯罪率
    'Land zone size',               // 1   zn       地区占地超过25000平方英尺的住宅用地比例
    'Industrial proportion',        // 2   indus    地区城镇非零售营业面积(工业面积)的比重
    'Next to chas river',           // 3   chas     地区是否毗邻查尔斯河
    'Nitric oxide concentration',   // 4   nox      地区一氧化氮浓度(百万分之一)
    'Number of rooms per house',    // 5   rm       地区每个住宅的平均房间数
    'Age of housing',               // 6   age      地区1940年之前建造的自有住房这部分的占比
    'Distance to commute',          // 7   dis      地区上下班往返的通勤加权距离(波士顿五个就业中心)
    'Distance to highway',          // 8   rad      地区离高速公路的距离(径向公路通达性指数)
    'Tax rate',                     // 9   tax      地区每10000美元的税率
    'School class size',            // 10  ptratio  地区学校班级大小(大班制还是小班制)
    'School drop-out rate'          // 11  lstat    地区未接受高中教育的在职男性百分比(男性辍学率)
];

// Shuffles data and target (maintaining alignment) using Fisher-Yates algorithm.
function shuffle(data, target) {
    let counter = data.length;
    let temp = 0;
    let index = 0;
    while (counter > 0) {
        index = (Math.random() * counter) | 0;
        counter--;
        // data:
        temp = data[counter];
        data[counter] = data[index];
        data[index] = temp;
        // target:
        temp = target[counter];
        target[counter] = target[index];
        target[index] = temp;
    }
}

normalization.js

/**
 * Calculates the mean and standard deviation of each column of a data array.
 *
 * @param {Tensor2d} data: Dataset from which to calculate the mean and std of each column independently.
 *
 * @returns {Object}: Contains the mean and standard deviation of each vector column as 1d tensors.
 */

// 计算数据数组中每列的平均值和标准差。
export function determineMeanAndStddev(data) {
    // TODO(bileschi): Simplify when and if tf.var / tf.std added to the API.
    // 目前tf暂未提供直接计算方差和标准差的API,因此下面通过手动计算方式来得到对应的值。

    // 计算平均值。
    const dataMean = data.mean(0);
    // data.shape();      // => [333, 12]
    // dataMean.print();  // => [12]
    // 0表示沿着轴0,对不同样本的同一个特征分别取平均值,最终将该2维张量降为1维张量,得到一个含有12个
    // 元素(12个特征)的1维张量,每个元素为该特征在所有样本中的平均值。

    // 计算和平均值的离差(也叫离均差)。
    const diffFromMean = data.sub(dataMean);
    // 这里执行sub减的时候,data和dataMean的shape是不同的,为了达到能进行sub的目的,tf先使用广播扩
    // 展了dataMean的shape,实际就是将其重复了333次,变成了和data一样的shape,然后再执行sub。

    // 计算离差的平方。
    const squaredDiffFromMean = diffFromMean.square();
    // 这里就是计算每个元素的平方值。

    // 计算方差,即离差的平方的平均数。
    const variance = squaredDiffFromMean.mean(0);

    // 计算标准差,即方差的算术平方根。因此,标准差也就是离均差(离差)平方的算术平均数(方差)的算术平方根。
    const dataStdDeviation = variance.sqrt();

    // 计算标准差的执行步骤可以通过链式方式直接表示为:
    // const dataStdDeviation = data.sub(data.mean(0)).square().mean().sqrt();

    return {dataMean, dataStdDeviation};
}

/**
 * Given expected mean and standard deviation, normalizes a dataset by
 * subtracting the mean and dividing by the standard deviation.
 *
 * @param {Tensor2d} data: Data to normalize. Shape: [batch, numFeatures].
 * @param {Tensor1d} dataMean: Expected mean of the data. Shape [numFeatures].
 * @param {Tensor1d} dataStdDeviation: Expected std of the data. Shape [numFeatures].
 *
 * @returns {Tensor2d}: Tensor the same shape as data, but each column normalized to have zero mean and unit standard deviation.
 */
// 给定预期平均值和标准偏差,通过减去平均值并除以标准偏差来归一化数据集。
export function normalizeTensor(data, dataMean, dataStdDeviation) {
    // 合并上下这两个方法,归一化的公式如下:
    // normalizedFeature = (feature - mean(feature)) / std(feature)
    return data.sub(dataMean).div(dataStdDeviation);
}

// 归一化最终的目的是使数据具有零均值和单位标准偏差。
// 本项目的数据量很大且维度是,这里以简单化的1维张量[10,20,30,40]为例演示上述计算过程中每一步的结果,
// 这里直接展示张量print出的值,以方便查看。
//
// const data = [10, 20, 30, 40];                       // => [10, 20, 30, 40]
// 计算平均值。
// const dataMean = data.mean(0);                       // => 25
// 计算和平均值的离差。
// const diffFromMean = data.sub(dataMean);             // => [-15, -5, 5, 15]
// 计算离差的平方。
// const squaredDiffFromMean = diffFromMean.square();   // => [225, 25, 25, 225]
// 计算方差,即离差的平方的平均数。
// const variance = squaredDiffFromMean.mean(0);        // => 125
// 计算标准差,即方差的算术平方根。
// const dataStd = variance.sqrt();                     // => 11.18
// 计算归一化。
// data.sub(dataMean).div(dataStd);                     // => [-1.34, -0.45, 0.45, 1.34]
//
// 最后[10,20,30,40]通过归一化处理后即为[-1.34, -0.45, 0.45, 1.34]。
// 我们可以从归一化处理后的数据中看到标准偏差约为1,使得预处理的数据被限定在一定的范围内,从而消除奇异样本数据
// 导致的不良影响(参数寻优的动荡、梯度爆炸或消失等),加快模型收敛速度,提高模型预测的准确度。
//
// 而奇异样本数据的产生,大多不可避免,因为不同的特征往往有不同的评价指标,不同评价指标往往又有不同的量纲及单位,
// 这样的情况就会产生奇异样本数据。比如就像本项目的样本数据一样,NOX的值和TAX的值的范围差异巨大。直接使用原始
// 数据进行模型拟合的话,会导致等高线出现一个类似于椭圆形的样子,进而导致梯度下降曲线不正常,可能会处于弯弯扭扭
// 的样子,也就是上面提到的"动荡",乐观的状态下梯度的方向会偏向最小值的方向,走很多弯路,进而导致训练时间过长。
// 而在悲观的状态下,情况甚至更糟,可能连梯度都没有了。无论哪种情况,即便最后训练出了模型,模型预测的准确度都是
// 不可信的。而做过归一化处理的数据,其等高线更接近于一个圆形的样子,梯度的曲线会变得自然。
//
// 归一化仅是机器学习中众多优化方法中的一个,其对速度的提升是最慢的,但对内存资源的消耗是最低的。
//
// 在上文内容中,提到了广播,深入介绍如下:
// 考虑张量运算,例如 C = tf.someOperation(A,B),其中 A 和 B 是张量。如果可能,并且没有歧义,将广播较小
// 的张量匹配较大张量的形状。广播包括两个步骤:将轴(称为广播轴)添加到较小的张量以匹配较大张量的等级。
// 在这些新轴旁边会重复出现较小的张量,以匹配较大张量的完整形状。
// 在实现方面,实际上不会创建新的张量,因为这将非常低效。重复操作完全是虚拟的,它发生在算法级别而不是内存级别。但是,
// 考虑到沿着新轴重复的较小张量是一个有用的思维模型。
// 在广播中,如果一个张量的形状为(a,b,…,n,n + 1,…m),而另一个张量的形状为(n,n + 1,…,m),则通常可以
// 应用两张量元素运算。然后,广播将自动从轴 a 到 n-1 发生。例如,以下示例通过广播将元素级最大运算应用于不同形状的
// 两个随机张量。
// x = tf.randomUniform([64,3,11,9]); #A
// y = tf.randomUniform([11,9]); #B
// z = tf.maximum(x,y); #C
// #A x 是形状为[64、3、11、9]的随机张量。
// #B y 是形状为[11,9]的随机张量。
// #C 输出 z 具有 x 的形状[64,3,11,9].

ui.js

import {
    NUM_EPOCHS, BATCH_SIZE, LEARNING_RATE,
    linearRegressionModel,
    multiLayerPerceptronRegressionModel1Hidden,
    multiLayerPerceptronRegressionModel2Hidden,
    run,
} from '.';

const statusElement = document.getElementById('status');

export function updateStatus(message) {
    // 更新项目提示状态。
    statusElement.innerText = message;
}

const superParameterElement = document.getElementById('super-parameters');

export function updateSuperParameter() {
    superParameterElement.innerText = `Epoch Number: ${NUM_EPOCHS}\n Batch Size: ${BATCH_SIZE}\n Learning Rate: ${LEARNING_RATE}`;
}

const baselineStatusElement = document.getElementById('baselineStatus');

export function updateBaselineStatus(message) {
    // 更新基线数据。
    baselineStatusElement.innerText = message;
}

export function updateModelStatus(message, modelName) {
    // 更新模型训练状态。
    const statElement = document.querySelector(`#${modelName} .status`);
    statElement.innerText = message;
}

const NUM_TOP_WEIGHTS_TO_DISPLAY = 5; // 只展示前5个权重。

/**
 * Updates the weights output area to include information about the weights
 * learned in a simple linear model.
 *
 * @param {List} weightsList: List of objects with 'value':number and 'description':string
 */
export function updateWeightDescription(weightsList) {
    // 更新按量级排名的前5个权重的信息。
    const inspectionHeadlineElement = document.getElementById('inspectionHeadline');
    inspectionHeadlineElement.innerText = `Top ${NUM_TOP_WEIGHTS_TO_DISPLAY} weights by magnitude`;
    // Sort weights objects by descending absolute value.
    weightsList.sort((a, b) => Math.abs(b.value) - Math.abs(a.value));
    const table = document.getElementById('myTable');
    // Clear out table contents
    table.innerHTML = '';
    // Add new rows to table.
    weightsList
        // .reverse() // 输出倒数5个。
        .forEach((weight, i) => {
            if (i < NUM_TOP_WEIGHTS_TO_DISPLAY) {
                const row = table.insertRow(-1);
                const cell1 = row.insertCell(0);
                const cell2 = row.insertCell(1);
                if (weight.value < 0) {
                    cell2.setAttribute('class', 'negativeWeight');
                } else {
                    cell2.setAttribute('class', 'positiveWeight');
                }
                cell1.innerHTML = weight.description;
                cell2.innerHTML = weight.value.toFixed(4);
            }
        });
}

export function setup() {
    // 获取各文本DOM。
    const trainSimpleLinearRegression = document.getElementById('simple-mlr');
    const trainNeuralNetworkLinearRegression1Hidden = document.getElementById('nn-mlr-1hidden');
    const trainNeuralNetworkLinearRegression2Hidden = document.getElementById('nn-mlr-2hidden');

    // 绑定按钮DOM点击事件回调,点击后执行对应神经网络的训练。
    trainSimpleLinearRegression.addEventListener('click', async () => {
        // 生成线性回归模型。
        const model = linearRegressionModel();
        // 训练模型。
        await run(model, 'linear', true);
    }, false);

    trainNeuralNetworkLinearRegression1Hidden.addEventListener('click', async () => {
        // 生成多层感知器回归模型(含1个隐藏层)。
        const model = multiLayerPerceptronRegressionModel1Hidden();
        // 训练模型。
        await run(model, 'oneHidden', false);
    }, false);

    trainNeuralNetworkLinearRegression2Hidden.addEventListener('click', async () => {
        // 生成多层感知器回归模型(含2个隐藏层)。
        const model = multiLayerPerceptronRegressionModel2Hidden();
        // 训练模型。
        await run(model, 'twoHidden', false);
    }, false);
}

 

posted @ 2024-05-21 13:27  james·von  阅读(6)  评论(0编辑  收藏  举报