2.4小时教你入门机器学习算法

现在很流行什么24小时精通xxx,我觉得24小时太久,不如试试2.4小时。
我会尝试用简单的说法解释复杂的事物。
人类一直在探索宇宙的真理,或者说探索宇宙的公式。周文王做《周易》也无非就是通过各种卦象推算出预言。再说直白一点,就是寻找y=f(x1,x2,x3,…)这样的公式。通过x1,x2,x3…去推导出结果y。

那么我私人定义一下机器学习的定义,通俗的说,就是让机器自己通过大量的样本(x1,y1)(x2,y2)…(xn,yn) (俗称大数据big data),推导出这个f(x1,x2,x3)的公式。注意,机器学习只会根据样本推导出最接近真相的公式,但是不能回答为什么,不能解释原理。
即使你从未接触过机器学习,你肯定也知道这是一门非常复杂的学科。那么入门显然要从最简单的讲起,这篇文章不会教你调用什么API,这样就变成了调包侠,我们从最简单的原理讲起。
作为一个民间科学家,首先打开百度百科,搜索一下“方差公式”,可以看到以下信息:

我们的故事就要从方差公式说起,即使上学期间是混日子的人都知道这个公式。事实上,我不能解释这个公式为什么这么定义,就当是大家公认的吧。这篇文章的例子都会遵循这个方差公式来定义稳定性。事实上,我们也可以自己定义自己的方差公式来定义自己系统的稳定性,在机器学习中,它的名字叫代价函数。
y=f(x1,x2,x3,…)是复杂的多维因子函数,作为入门教程,我们把它简化成只有一个输入因子的函数y=f(x)。我们的目的就是根据样本求出这个f()。
作为一个民间科学家,再打开百度百科,搜索一下“泰勒级数”

这里我来更加通俗的解释一下泰勒级数,我知道入门教程阅读者不会去学习太深的数学基础,所以我只会很直白的解释,说白了,泰勒就是想把任意的y=f(x)转换成好计算的多项式,本质就是把一条任意规律的线解释成多项式子的和。因为一条线想等同于另外一条线,只要他们在任意x点的导数相同,导数的导数相同,导数的导数的导数相同,导数的导数的导数的导数相同….那么他们就相同,因此我们可以把任意的y=f(x)分解成a*x^n + b*x^(n-1) + ……+ c*x^(1) + d 这样的多项式,问题就可以进一步简化。
作为入门教程,我们把难度收敛到多项式中最简单的一项:c*x^(1),什么?你说d是最简单的一项?那好吧,那就收敛到第二简单的一项。也就是y=ax这样的一条直线。

也就是说任何复杂的公式,最终都是由若干的y= θx或者y= θx+b组成。从几何上来说,这就是一条直接,因此最简单的机器学习就是基于线性的回归。

干货:

假设我们拥有若干y=θx的样本,目的就是求出θ。那么我们就应该力求最一个θ使得样本的方差公式的结果最小,这样就最稳定,最符合样本的真相。注意,这很重要,这就是贯穿机器学习的核心方法论,让代价函数的值最小。

为了求出θ,我们定义一个关于θ的函数: z= J(θ).由此可知,这个函数应该是这样的:

也就是方差公式。得到合适的θ值让z最小则成为了我们新的目标。
从习惯方便理解的方向,转换成对θ的公式。我们把这个公式转换习惯的y = f(x)公式。我们可以把x和y替换成a和b,把θ替换成x。想象成新的公式就应该是:

格式化写上面这个公式,费了我很多力气,所以接下来,还是让灵魂画手上场吧。

这个函数基本上是就是J(θ)的图形,嗯,一个U形(当变量变成2维之后,你可以想象就是一个立体的碗的形状,如果是3维就是,emo,我也想象不出来了),那么我们的目的就是要求出一个θ值让曲线的值最小最低。
有一个公式,可以让θ逐渐逼近最低点,这个过程,在机器学习中称为梯度下降法。假设我们初始设置θ值为0,然后让θ值变成θ值减去一个偏移,直到这个J(θ)的导数成为0,那么就找到了最低点。

我们从下图来理解一下:

假设θ在最低值的左侧,对这个点求导数,也就是切线,可以得到一个很小的∆z和∆θ,事实上他们的比就是这个点的导数,那么这是一个负数,因此就会让θ变大,然后右移,同理,我们取值在右侧,就会让θ变小,然后左移,当α比较小的时候,就会逐渐逼近最低点,直到导数为0 或者无限接近0的时候,就是最低点了。
上面我们得出那么也就是

talk is cheap, show you the code.

首先我们定义一些点的用例case

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Case {

    private double x;

    private double y;

    public static Case of(double x, double y) {
        return new Case(x, y);
    }
}

我们假定有一个y=f(x)的最简单的线性函数是y=θx,这里θ我们设置成一个随便设置的变量,可以得到原始函数

    private static final double REAL_SITA = 12.52D;

    /**
     * 原始函数
     *
     * @param x x值
     * @return y值
     */
    public double orgFunc(double x) {
        return REAL_SITA * x;
    }

然后构造1000个样本用例,并且假设用例不是很标准,有5%的误差,这样更加真实

    private static final int CASE_COUNT = 1000;
    private final List<Case> CASES = new ArrayList<>();
    
    /**
     * mock样本
    */
    private void makeCases() {
        List<Case> result = new ArrayList<>();
        for (int i = 0; i < CASE_COUNT; i++) {
            double x = ThreadLocalRandom.current().nextDouble(-100D, 100D);
            double y = orgFunc(x);
            boolean add = ThreadLocalRandom.current().nextBoolean();
            /* 为了仿真,y进行5%以内的抖动 */
            int percent = ThreadLocalRandom.current().nextInt(0, 5);
            double f = y * percent / 100;
            if (add) {
                y += f;
            } else {
                y -= f;
            }
            Case c = Case.of(x, y);
            result.add(c);
        }
        /* sort排序 */
        result.sort((o1, o2) -> {
            double v = o1.getX() - o2.getX();
            if (v < 0) {
                return -1;
            } else if (v == 0) {
                return 0;
            } else {
                return 1;
            }
        });
        this.CASES.clear();
        this.CASES.addAll(result);
    }

由于之前我们得到了求θ的导数公式,因此我们可以这样计算导数

    /**
     * J(θ)的导数
     *
     * @param sita θ
     * @return 导数值
     */
    public double derivativeOfJ (double sita) {
        double count = 0.0D;
        for (Case c : CASES) {
            double v = sita * c.getX() * c.getX() - c.getX() * c.getY();
            count += v;
        }
        return count / CASE_COUNT;
    }

最后我们设置一个小一点的α,然后假设 θ初始值为0,让 θ自己不停的去修正自己,得到最后的 θ值

    /**
     * J(θ)的导数
     *
     * @param sita θ
     * @return 导数值
     */
    public double derivativeOfJ (double sita) {
        double count = 0.0D;
        for (Case c : CASES) {
            double v = sita * c.getX() * c.getX() - c.getX() * c.getY();
            count += v;
        }
        return count / CASE_COUNT;
    }

    /**
     * 梯度下降
     */
    public double stepDownToGetSita() {
        double alpha = 0.0001D;
        /* 假设θ从0开始递增 */
        double sita = 0D;
        while (true) {
            double der = derivativeOfJ(sita);
            /* 由于计算机double有精度丢失,当导数der无限趋于0,则认为等于0 */
            if (Math.abs(der) < 0.000001) {
                return sita;
            }
            /* 不然就修正θ */
            sita -= alpha * der;
        }
    }

最后我们写一个junit来简单测试一下,看看能不能模糊计算出我们预先设置的θ值,和REAL_SITA比精度能到多少

    @Test
    public void test() {
        LineFunction lineFunction = new LineFunction();
        lineFunction.makeCases();
        double x = lineFunction.stepDownToGetSita();
        log.debug("x:{}", x);
    }

最后输出结果

可见,预期值是12.52,我们计算出来是12.520841
什么?你说结果不是很精确?嗯,第一,这只是一个POC,第二,样本数量太少,第三,样本我进行了5%的模糊化,导致和本身的函数确实有误差。
嗯,机器学习的入门就是这样了,往后算子的复杂度会越来越高,但是原理就是这样。