Athrun & Aaron

the done is done

All posts in one long list


Swift for Tensorflow

本文是我在 atSwift Conf 2018 上的分享的文字版。内容和 keynote 高度重合,不过因为是文章,一些地方解释得会更加到位一些。如果看过 keynote 的同学可以不用看了。

背景

首先一个问题是:为什么要关注 Swift 在机器学习领域的应用? 从语言层面来看,Swift 兼具了脚本语言快速开发迭代的特性以及 low level 的编译型语言的性能。而效率和性能是后端开发考虑的两个主要方面,所以这也不能解释为什么 Server-Side Swift 受到越来越多的关注。概括来说,Swift 具备以下优势:

  1. 高性能;
  2. 开源并且社区非常活跃,语言本身也在高速发展与迭代;
  3. 开发效率很高,并且语言级别的静态类型和异常处理使得代码非常安全;

Next Big Thing on Server

既然说到 server 开发,我们不妨认真地思考一下最近几年的 server 开发都在讨论什么,云计算和容器化极大程度的提高运维效率之外,最重要的一大改变就是基于机器学习的应用呈现出了井喷式的发展,其中主要包括以下几个方面:

  1. 推荐系统,几乎运用到了互联网的所有服务中;
  2. 文本分析,推荐系统的基础能力之一,苹果在 iOS 中也非常重视 NLP 能力的完善与建设;
  3. 音视频理解,音视频由于其数据的特殊性,长久以来我们只能从信号学层面做一些粗浅的分析,近年来得益于卷积神经网络在计算机视觉领域的成功应用,越来越多的公司在使用这些技术尝试从视频中提取更多的结构化信息;
  4. 超分辨率,简单的说就是小图变大图,曾经我们普遍使用双线性插值和双三次插值,近年来通过试用卷积神经网络技术能够更好地猜出变大后图片的细节,也有广泛的应用。
  5. 聊天机器人,这个就不用说了,智能化客服基本是已经产业化的技术,虽然有时候会被调侃为人工智障,但不可忽视的是这项技术已经切实为很多公司节省了客服的人力成本。

目前机器学习应用主要的开发语言是 C++ 和 Python,Python 用于快速验证想法,验证之后用 C++实现后部署到线上系统,这两门语言的优缺点都比较明显,C++性能很强,但门槛高,开发效率低,Python 容易写,但也非常容易写出很多垃圾代码。所谓动态类型一时爽,重构起来火葬场。

我们不禁会想,既然 Swift 是一门博众家之长的语言,那假如它能提供很好的机器学习能力,岂不是非常完美?

本文的主角解决的就是这个问题 —— Swift for TensorFlow

Swift for TensorFlow 概览

Swift for Tensor, 简称 TFiwS( 为什么这么叫,官方并没有解释,我大胆的猜测一下,应该是正读看起来像TensorFlow for Swift, 倒过来读就是 Swift), 文章接下来会简称 TFS.

TFS 是今年 Chris 在2018 TensorFlow Dev Submit 上发布的,到现在也没满一年,是一个非常年轻的技术,现在也不算正式发布,但已经开源了。目前正在 active developing 的阶段,基本上每两周会有一个新的版本放出来。

TFS 有如下几个优势:

  1. 可以融入完整的 TF 生态系统,众所周知目前 TF 的生态是最完善的;
  2. 不仅仅只是 TensorFlow 的 API binding,而是提供了 First class machine learning 的能力;
  3. 基于2中有趣的特性(后面会讲),使得 TFS 同时支持跑 CPU、GPU 以及远程的 TPU;
  4. 兼具 可用性高性能
  5. 最后一点,支持 Xcode Playground,使得天生具备“Notebook”一样的感觉;

简单的例子:

那个诡异的实心圆代表矩阵乘法,等价于 matmul

那到底什么才是所谓的“First class machine learning”, 以及为什么说 TFS 是兼具可用性和性能呢? 我们还需要看 TF 的两种模式。

TensorFlow 的两种经典模式

  1. 图模式。代码如下:
#!/usr/bin/python
#coding=utf8
"""
# Author: aaron
# Created Time : 2018-09-01 16:50:36

# File Name: graph.py
# Description:

"""

import tensorflow as tf

x = tf.constant([[1,2,3], [4,5,6]])
xt = tf.transpose(x)
y = tf.matmul(x, xt)

with tf.Session() as sess:
    print sess.run(y)

图模式的一个重要特点就是:Lazy Evaluation。比如在执行 xt = tf.transpose(x) 的时候,实际上并没有触发矩阵转正的运算,而是只是生成了一个名为”转置”的运算节点,添加到了计算图中。最后,当执行 sess.run(y)的时候,所有计算才开始运行。

由于这个独特的模式,图模式的优点很明显,那就是性能很强,因为计算时已经知道了所有的计算节点,可以做很多优化,同理,缺点也明显,可用性很差,先建图后计算的模式并不符合直觉,而且只能使用节点支持的运算来构建计算图。Debug 起来很痛苦,因为你并不能通过 print x 这样的形式来 debug 一些中间变量(执行到 print 的时候 x 还没有 value) 。

  1. Eager Execution 模式。示例代码如下:
#!/usr/bin/python
#coding=utf8
"""
# Author: aaron
# Created Time : 2018-09-01 16:59:01

# File Name: eager.py
# Description:

"""

import tensorflow as tf
tf.enable_eager_execution()

x = tf.constant([[1,2,3],[4,5,6]])
y = tf.matmul(x, tf.transpose(x))
print(y)

顾名思义,Eager 模式与图相反,计算是立即发生的,整个过程并不会构建图。所以可以使用自然的流程控制,比如 if 语句来书写模型,也可以在执行的过程中插入 print x 来获取中间值,帮助我们 debug。

Eager 模式更符合直觉,易于理解,易于 debug。但因为没有 lazy,所以很难做优化,性能并不好。

第三种模式 — TFS 模式

以下是一段 Swift 代码:

    let x : Tensor<Float> = [[1,2,3], 
                             [4,5,6]]
    var y = Tensor<Float>(zeros: 
                            x.shape)
    print(y)
    if x.sum() > 100{
        y = x
    }else{
        y = x • x
    }
    print(y)

上面的代码看起来是 eager 的,但其实跑起来是以 graph 的形式跑的,所以具备图模式的所有优化。TFS 实现了一个改进版的 Swift 编译器,在编译阶段会自动分析代码中的 tensor 运算,并翻译成计算图,最终执行的时候就以图的模式运营。而且这个过程对程序员透明,也就是说:

程序员只需要当它是 eager 的模式来写代码,最终跑起来就和图模式一样快了

听起来是不是不太现实? 这是什么做到的呢? 我们进入下一个章节。

TFS 模式的原理:Program Slicing

上文提到,TFS 会在编译期间分析出所有的 tensor 运算,并生成计算图,我们先来看看具体是怎么做的,计算图由 TensorFlow Runtime 运行的。

简单的例子

先从一段简单的代码开始:

typealias FloatTensor = Tensor<Float>

func linear(x : FloatTensor, w : FloatTensor, b : FloatTensor) -> FloatTensor
{
    let tmp = w • x
    let tmp2 = tmp + b
    return tmp2
}

代码很直观,计算 wx + b ,只是以矩阵的形式。为了简单期间,我们没有使用变量,而是都用的常量。

如果是针对上面的代码,要怎么生成计算图的? 既然 Swift 没有 runtime,我们不妨来看看其对应的 AST。

可以看到,所有 Tensor 类型的值和变量都可以从 AST 中找出来,也就是说,我们只需要遍历这个 AST,就可以得到所有 Tensor 相关的常量和对应的操作,自然就可以构建计算图。如下图所示

以看到,这种在编译期间生成计算图的计算几乎强依赖于 Swift 的静态类型系统,所以这活儿 Python 还真不一定适合,Chris 在 TFiwS 的白皮书也介绍了为什么使用 Swift,感兴趣的可以点这里:https://github.com/tensorflow/swift/blob/master/docs/WhySwiftForTensorFlow.md

生成计算图后,代码该怎么执行呢?我们只需要将图代码移除后,替换为启动图计算的代码即可,上述代码变换后如下所示:

typealias FloatTensor = Tensor<Float>
func linear(x : FloatTensor, w : FloatTensor, b : FloatTensor) -> FloatTensor
{
    let result = execTensorFlowGraph("graph_generate_before")
    return result
}

上述代码运行时,直接调用 TensorFlow Runtime 执行刚才生成的计算图,取得执行结果后返回

复杂的例子

现在我们来看一个复杂一点的例子:

func linear(x : FloatTensor, w : FloatTensor, b : FloatTensor) -> FloatTensor
{
    let tmp = matmul(x, w)
    let tmp2 = tmp + b
    print(tmp2)
    let tmp3 = tmp2 * magicNumberGenerateFromTensor(x: tmp2)
    return tmp3
}

func magicNumberGenerateFromTensor(x : FloatTensor) -> Float
{
    return 3.0
}

和上一个例子不同的是,这里我们添加了一个真 eager 模式的代码,print(tmp2) , 不仅如此,在 tmp3的计算中,我们还耦合了 tensor 的逻辑和普通的本地运算: magicNumberGenerateFromTensor,在这样的前提下,我们生成的图是什么样的呢?

tensor逻辑,代表最终要生成 graph,由 TensorFlow Runtime 运行的逻辑,或者简单的理解为 Tensor 类的相关操作都是 tensor 逻辑。 而本地运算,则是由 Swift Runtime 执行的非 tensor 逻辑的代码。

之前的思路,我们可以得到上述的图,但存在两个问题:

  1. 我们需要打印出 tmp2 ,也就是 wx+b的值,但是这个图是以一个整体由 TensorFlow 执行的,如何自动拿到某个中间的值?
  2. magic 是由本地逻辑计算得到,TensorFlow 在执行这张图的时候,怎么知道 magic 的值到底是多少呢?

Program Slicing

接下来就是我们的 Program Slicing 技术登场了,这并不是一项新技术,但这应该是它第一次被用来干这事儿,详细介绍大家可以自行 wikipedia。

大家看完上面的问题,会发现一个终极问题是,本地代码和 graph 似乎有双向通信的需求。TFS 实现这样的双向通信,主要是通过 Program Slicing 技术,将原始代码转换为两份不同的代码,一份用来生成图,一份用来在本地跑。两份代码互相通信,完成计算。

我们再 review 一下原始代码,然后开始 slicing!

原始代码:

func linear(x : FloatTensor, w : FloatTensor, b : FloatTensor) -> FloatTensor
{
    let tmp = matmul(x, w)
    let tmp2 = tmp + b
    print(tmp2)
    let tmp3 = tmp2 * magicNumberGenerateFromTensor(x: tmp2)
    return tmp3
}

1.Graph 部分 移除所有本地代码,然后针对两种情况做处理: a. 如果本地代码依赖图代码的结果,则插入 tfop(“send”, 用于指明这一步需要将结果发送给本地代码; b. 如果图代码依赖本地代码,则插入 tfop("receive"), 用于接受本地代码发过来的结果

改造完毕的图代码如下所示:

func linear(x : FloatTensor, w : FloatTensor, b : FloatTensor) -> FloatTensor
{
    let tmp = matmul(x, w)
    let tmp2 = tmp + b
    //REMOVED: print(tmp2)
    tfop("send", tmp2)
    let result = tfop("receive")
    // REMOVED: magicNumberGenerateFromTensor(x: tmp2)
    let tmp3 = tmp2 * result
    return tmp3
}

2.本地部分 逻辑同上,删除图相关代码,针对依赖/被依赖的部分插入 send/receive 操作,区别只是需要在开头插入启动图代码,以及最终从图的部分拿到结果返回。

本地版本的代码

func linear(x : FloatTensor, w : FloatTensor, b : FloatTensor) -> FloatTensor
{
    let tensorProgram = startTensorGraph(graphName: "GeneratedGraphName")
    //REMOVED: let tmp = matmul(x, w)
    //REMOVED: let tmp2 = tmp + b
    let tmp2 = receivedFromTensorFlow(tensorProgram)
    print(tmp2)
    let result = magicNumberGenerateFromTensor(x: tmp2)
    sendToTensorFlow(tensorProgram, result)
    let tmp3 = finishTensorGraph(handle: tensorProgram)
    //REMOVED: let tmp3 = tmp2 * magicNumberGenerateFromTensor(x: tmp2)
    return tmp3
}

从一份代码,slice 出两份之后,将graph 部分编译成图,最终结构如下图所示:

整个执行过程:

  1. 执行本地 startTensorGraph,触发图开始计算;
  2. TensorFlow Runtime 开始计算图;
  3. 计算完毕 wx + b 后,发现有SEND 节点,于是将结果发给本地;
  4. 本地代码收到结果,如代码所写, 执行 print;
  5. 本地代码调用函数,计算 magicNumber,并将结果发送给 TF Runtime;
  6. TF Runtime 收到 magic 后,开始结算最终的结果 tmp3 ,并发回到本地;
  7. 本地收到 tmp3 ,返回结果。

以上,就实现了: 写 eager 的代码,但跑起来具备图的性能,并且像 eager 模式一样支持本地的控制流程和用 print 进行 debug。 当然,背后还有很多黑魔法,这里只是讲了最简单的一个 use case……具体的大家可以去看 TFS 的白皮书。

看到这里大家会想 SEND 节点和 RECV 节点是啥,为什么 TensorFlow 有这么骚的操作? 其实这两个东西本来是让 TensorFlow 做分布式计算的,在不同的机器上同步结果。所以这个模式能 work,关键还是 TensorFlow 本身具备一个比较牛逼的架构。

通用的流程如下图所示:

实战 — 线性分类问题

在了解了 TFS 大致的原理之后,这里讲一个机器学习中非常基础的例子,来体验一下 TFS 开发机器学习类应用的便利性。

问题描述

海洋的某片区域受到了污染,经过了五十三次勘探,我们得到了如下的分布图:

其中,绿色的圆代表该位置是干净的,红色的方块代表该位置受到了污染。

问题就是,当给一个新的坐标 (x1’, x2’) ,我们能否基于之前的勘探数据推测出该位置是否被污染?

问题分析

首先,通过观察图,很明显污染和被污染是线性可分的,那我们可以假设,我们要寻找的是一条直线,假设为 L. 我们可以得到如下的模型:

\[Line\left( X \right)=\; w_{0}\; +\; w_{1}x_{1\; }+\; w_{2}x_{2}\; =\; W^{T}X\]

之所以可以写为左右的矩阵形式,我们只需要假设存在x0并且x0恒为1,这样就可以把w0也带一个x0的系数,进而整体写成矩阵的形式。

上述模型有三个权重,w0,w1,w2,这就是我们要通过训练数据来计算的。不过现在我们先思考一个终极问题:假设我们已经有了w0~w2,我们怎么用这个线性模型来判断一个新的坐标的分类呢?

首先我们的模型 Line 的值域很明显是负无穷到正无穷,那我们要针对这样的一个连续值做分类,有两种方法:

  1. 设定一个阈值,超过阈值为0,否则为0;
  2. 使用其他函数将 Line 的值 map 到0到1之间;

很明显方法1是不合理的,因为不同的问题尺度不同,可以脑补一条夹角为45°的直线,不管你设多大的阈值,仍然可能会出现大于该阈值的点会同时包含两种分类。

机器学习一般使用方法2,通过一个叫做 sigmoid 的函数来进行映射,该函数的特征如下图所示: 从图中可以看出,这个函数的形状很完美,定义域负无穷到正无穷,值域从0到1,并且整体很平滑。 我们用 sigmoid 来处理 Line 的结果,得出新的模型: \(Classifier\; =\; sigmoid\left( Line\left( X \right) \right)\; =\frac{1}{1\; +\; e^{-\left( W^{T}X \right)}\; }\;\) 既然已经知道如何用模型来计算分类了,现在是时候来计算模型本身了,如刚才所说,计算模型也就是计算 W 向量(包含三个分量)。那如何计算 W 呢? 或者说什么样的 W 才是好的 W 呢?基于我们已经有了 53 次观测,于是有如下事实:

  1. 如果我们能找到一个 W,用这个 W 去计算观测点的分类,如果能尽可能的贴合我们之前实际观测到的 W,则就代表找到了一个好的 W
  2. Classifier(X’) 的值在0-1之间,我们可以当做是 X’ 是类别1的概率

于是我们可以发现这很适合用“极大似然估计”来建模该问题,可以得到一个关于W 的方程: \(l\left( W \right)=\sum_{i=1}^{m}{\left( label^{i}\log \left( classifier\left( x^{i} \right) \right)\; +\; \left( 1\; -\; label^{i} \right)\log \left( 1\; -\; classifier\left( x^{i} \right) \right) \right)}\) 根据极大似然估计的定义,我们只需要找到一个 W,使得上述函数取得最大值,则 W 就是一个最优解。至此,我们成功的将一个分类问题,转换为一个数学上的优化问题。

找极值最常规的做法是梯度下降/上升,结合梯度上升以及对上式求导可得梯度的迭代公式: \(w_{j}=w_{j}+a\sum_{i=1}^{m}{\left( label^{i}\; -\; classifier\left( x^{i} \right) \right)}x_{j}^{\left( i \right)}\; \left( j=0..2 \right)\) 为了简化表示,通过观察上式,不难发现可以进一步写成矩阵的形式: \(W=W\; +\; aX^{T}\left( label\; -\; classifier\left( X \right) \right)\)

这个解决线性分类问题的方法也称作 logistics regression,对数几率回归。

代码实现

首先,先对我们的分类问题做一些基本的定义:

typealias FloatTensor = Tensor<Float>
let matplot = Python.import("matplotlib.pyplot")

enum Label : Int{
    case Green = 0
    case Red
}

struct ClassifierParameters : ParameterAggregate {
    var w = Tensor<Float>(randomNormal: [3,1])
}

struct Model
{
    var parameters : ClassifierParameters = ClassifierParameters()
}

这里,我们使用 TFS 中的 ParameterAggregate 结构来声明我们的模型,简单的来说就是一个 shape 为(3,1)的 tensor,代表三个 w。 接下来,我们把测试数据 load 到 TFS 的 Tensor 结构中。

func loadTrainingSet() -> (trainingVec : FloatTensor , labelVec : FloatTensor)
{
    let lines = try! String(contentsOf: URL(fileURLWithPath: "test.txt")).split(separator: "\r\n")
    let data = lines.map{$0.split(separator: "\t")}
    let rowCount = data.count
    let trainingScalars:[[Float]] = data.map{[1.0, Float($0[0])!, Float($0[1])!]}
    let labelScalarts:[Float] = data.map{Float($0[2])!}
    
    let trainingVec = Tensor<Float>(shape: [Int32(rowCount), 3], scalars: trainingScalars.flatMap{$0})
    let labelVec = Tensor<Float>(shape: [Int32(rowCount) , 1], scalars: labelScalarts)
    return (trainingVec, labelVec)
}

下一步,就是最关键的训练了,我们先 review 一下我们梯度上升的核心公式: \(W=W\; +\; aX^{T}\left( label\; -\; classifier\left( X \right) \right)\)

func train(trainingVec : FloatTensor, labelVec : FloatTensor, model : inout Model){
    let learningRate:Float = 0.0005
   
    for epoch in 0...3000{
        let y = trainingVec • model.parameters.w
        let h = sigmoid(y)
        let e = labelVec - h
        let dw = trainingVec.transposed() • e
        
        let grad = ClassifierParameters(w: dw)
        model.parameters.update(withGradients: grad) { (p, g) in
            p += g * learningRate
        }

        let p1 = -1 * labelVec * log(h)
        let p2 = (1 - labelVec)*log(1 - h)
        let traditionalLogLoss = ((p1 - p2).sum() / batchSize)

        print("epoch: \(epoch), LogLoss v2: \(traditionalLogLoss)")
    }
}

大家可以很容易看到,for 开头的四个 let,其实就是用非常简单的方式实现了核心的迭代公司。虽然我们分析得很复杂,但最终整个过程实现却非常简单,必须感谢矩阵这种东西,避免了无数的 for 循环。

最后,在得到 w 之后,我们可以把我们通过模型找到的直线画出来

func plot(trainVec : FloatTensor, labelVec : FloatTensor, parameters : ClassifierParameters)
{
    //Calculate points based parameters (w0, w1, w2)
    //…

    let matplot = Python.import("matplotlib.pyplot")

    let fig = matplot.figure()
    let ax = fig.add_subplot(111)
    ax.scatter(coord1x, coord1y, 50, "red", "s")
    ax.scatter(coord2x,coord2y,50, "green")
    ax.plot(xpts, ypts)
    matplot.show()
}

最终的效果如图所示:

线性分类的完整代码在这里:https://github.com/aaaron7/tfiws_snippet 有兴趣的童鞋可以下来跑一跑。

总结

Swift for TensorFlow 通过一系列有趣的技术,给 Swift 带来了内置的机器学习能力,实现了高可用性以及高性能,即便现在非常早期,支持的 TF Api 还不够全,但已经能够做出很多非常有趣的事情了。

TFS 的原理,更是 Programming language 和 machine learning 一次有趣的碰撞,有兴趣可以去看看白皮书,是很有想象空间的一个方向。

可能是最容易理解的 KMP 教程

背景

KMP 是经典的字符串匹配算法,数据结构的课上会讲,但绝大多数讲述的方式都很违反直觉并直接导致很容易忘记。除了课本,很多博客也都会分析原理和执行过程,不过并没有改善,绝大多数讲的也基本和课本差不多,很多上来就给一个公式+部分匹配表,反正我是看不懂。基于此,在这里,分享一下我的分析过程。

首先要明确的一点是,KMP 算法本身包含两个步骤:

  1. KMP 算法本身的思想与策略;
  2. 利用动态规划的思想来求一个字符串所有前缀子串的最大共同前后缀子串的长度(也就是一直在说的 next 数组);

两个部分其实关系不大,但很多文章都很喜欢将其混在一起讲。我们首先先讲第一步。

朴素的字符串匹配

以 leetcode 28 为例,实现 substr, 在字符串 haystack 中,找字符串 needle 的位置,不存在则返回-1.

最符合直觉的方法就是穷举法,遍历 haystack 中每个字符,对于每个字符,都尝试以该字符作为开始于 needle 进行逐个字符串的比较,匹配上了则返回当前的 i。整个过程可以看做是模式串(needle)从对齐 haystack 第一个字符开始逐步一个一个往后移动的过程。

假设 haystakc=”Hello Hella”, need=”Hella”, 过程如下:

i=0:
Hello Hella
Hella

i=1:
Hello Hella
 Hella

i=6:
Hello Hella
      Hella(matched)

整个过程一共执行了 6 次检查。朴素的匹配方式实现版本如下:

 	int strStr(string haystack, string needle) {
		if (needle.empty()){
			return 0;
		}
		
		if (needle.length() > haystack.length()){
			return -1;
		}

		int i = 0;
		
		while(i<haystack.length()){
			if (haystack.length() - i < needle.length()){
				return -1;
			}
			int j = 0;
			for (j =0 ;j<needle.length();j++){
				if (haystack[i+j] != needle[j])
					break;
			}
			
			if (j >= needle.length()){
				return i;
			}
			i += 1;
		}
		return -1;
	}

尝试优化

分析一下刚才 i=0 的执行过程。H、e、l、l 都匹配成功,仅最后的 o 和 a 匹配失败。此时移动模式串 Hella,我们很容易发现接下来的几步(i=1到 i=5)都是徒劳无功,我们是否可以利用先验知识(比如 Hell 之前已经匹配成功)来避免这几次比较的浪费呢?

首先做一个大胆的假设,当基于主串的第 i 个字符,一旦模式串的第 j 位匹配失败。当 j>0时,则主串的 [i, i+j)区间直接跳过,从 i+j位置开始搜索,当 j 小于=0 时,则直接从 i+1的位置开始搜索,简单的来说就是比如有:

i=0
Hello Hella
Hella(此时 j = 4, 代表模式串第五位匹配失败)

则下一次直接从 i+j = 4位置开始搜索

i=4
Hello Hella
    Hella(j=0,失配,下一次 i=i+1 = 5)

i=5
Hello Hella
     Hella(j=0,失配,下一次 i=i+1 = 6)

i=6
Hello Hella
      Hella(matched)

基于我们的假设,整个循环次数从 6 次减少为 4 次。这个假设的核心思想就是,某次失败的匹配中已经部分匹配的内容,认为这些内容不会出现在最后的匹配中,直接丢弃(跳过)

BAD CASE

上面的是一个假设,有没有可能存在不满足该假设的 bad case 呢。比如看以下的例子, 主串=”HelloHelloHead” 模式串=”HelloHead”

i=0
HelloHelloHead
HelloHea (a 和 l 不匹配,此时 j=7)

按照刚才的公式,直接跳过已经匹配好的部分,跳到 i+j = 7处开始下一次匹配

i=7
HelloHelloHead
       HelloHead (匹配失败,i=i+1)

i=8
HelloHelloHead
        HelloHead (匹配失败)

我们知道这个 case 应该是能够匹配成功才对,但是按照我们的假设流程却失败了。通过分析过程能够发现,本质上是我们跳过得太多了。那到底要跳过多少比较合适呢?先回头看看例子

i=0
HelloHelloHead
HelloHea (a 和 l 不匹配,此时 j=7)

j=7 的位置不匹配,但能看到前面两个位置是 H 和 e并且是匹配成功的,加上模式串的开头,也是 H 和 e,看起来可以**直接把开头的 He,对齐到后面的 He**,也就是i=5 的位置

i=5
HelloHelloHead
     HelloHead(matched)

那更新一下我们的假设,当匹配失败时,直接跳到 i+j的位置开始新的匹配,但对于某些case,需要少跳几步

KMP 算法

KMP 算法的本质就是定义了什么样的情况需要少跳,以及具体少跳几步。

其实从刚才的 case 不难发现,需要少跳的步数就是模式串已经成功匹配的部分的共同前后缀的长度, 比如刚才的 HelloHead,已经成功匹配的部分是 HelloHe,这个字符串具备共同的前后缀 He,长度为2.

共同的前后缀简单的理解就是前缀和后缀相同的字符串,比如 ABBA,共同前后缀是 A; ABBABB,共同前后缀是 ABB;ABBABBAAC, 无共同前后缀。

基于此,我们进一步更新我们的假设,当匹配失败时,如果已经匹配的模式子串无共同前后缀,则直接跳到 i+j的位置开始新的匹配,若存在共同子串,则跳转到 i+j-共同子串长度的位置开始新的匹配

因为模式串每一位都可能发生失配,所以我们需要求出模式串所有前缀子串分别的最大相同前后缀子串长度。比如模式串是 ABCDABC,我们需要分别求出 A、AB、ABC、ABCD、ABCDA、ABCDAB、ABCDABC 分别的最大相同前后缀的长度,比如 ABCDAB 是 2(AB),ABCDABC是 3(ABC),而 ABCDA 是 1(A)。这部分最后的结果存放在一个数组,普遍称之为 next 数组(千万别去纠结为什么叫 next)。

求最大相同前后缀的方法,朴素的可以分别生成前缀子串和后缀子串进行匹配可以得出,但这样效率比较慢,而且存在重复匹配。

KMP 算法 —— 动态规划的思想求 next 数组

既然利用动态规划思想,我们首先需要寻找最优子结构,即如何把一个问题拆解为子问题。那这个例子来说,我们需要思考当我们知道 next[i]的情况下如何求解 next[i+1]。

求解过程如下:

  1. 首先我们设一个临时变量 j,让其等于 next[i], 也就是 j=next[i] = [0,i-1]这个前缀子串的最大相同前后缀子串长度。 假设模式串为 s,即 s[0,j-1] == s[i-j,i-1];
  2. 这个时候我们要求 next[i+1], 即把 s[i] 这个字符加到 s[0,i-1] 这个子串末尾后,最大相同前后缀的子串长度。这个时候就有两个分支;
  3. 如果有 s[i]==s[j],基于 1 点末尾的等式, 则有 s[0,j]== s[i-j,i]。那显然新的子串的最大相同前后缀子串长度就是上一次迭代的结果+1, 即 next[i+1] = next[i]+1;
  4. 如果 s[i]!=s[j], 则代表位置 i 之前已经匹配的、长度为 j 的最大子串已经 gg 了,所以需要去寻找新的最大相同前后缀子串长度,这里设该新的长度为k,显而易见k 肯定小于 j,因为 s[i]和 s[j]已经失配了,所以 k 只可能出现在[0,j)这个空间
  5. 那这个 k 怎么找呢? 其实我们只需要做一个简单的转换,就会发现 k 其实就是 next[j], 具体如图:

IMAGE

核心就是根据两条绿色区域相等,所以可以将对 k 的求值转换为在第一条绿色线条空间内进行,之后根据我们对next 的定义,即可轻松得出 k = next[j]

刚才的过程举个例子:

我们有模式串:
abcabdssabcabceqqq
            ↑

假设执行到 a 的位置,此时 i= 12, next[i] = j = 4, [0,i-1]的最大相同前后缀子串为 abca。

现在来求 next[i+1], 也就是 next[13], 比较 s[i] 和 s[j], 都是 b,相等,则 s[13] = s[12] + 1 = 5 ,最大相同前后缀子串为 abcab

abcabdssabcabceqqq
             ↑
下一步,i 更新为13,j更新为 5,s[i]为 c,而 s[j]是d,不相等,则开始寻找 k,按照刚才的流程,首先更新 k 为 next[j],也就是 next[5],明显这里 next[5] = 2,最大相同前后缀子串为 ab。 于是我们更新 j 到 k 的值,也就是 2.

更新完 j 后继续测试 s[i] 与 s[j], 都等于 c。于是更新 s[i+1] = s[j]+1 = 3, 也就是 s[14]的 next 值为 3, 最大相同前后缀子串为 abc.(这一步), 如果不相同则继续下探 j 的值(j=next[j])

到现在,我们已经实现了如何基于 next[i] 求得 next[i+1], 接下来只需要分析初始值,比如当 i=0时,此时没有子串,故 j为-1.之后每次搜索当一直失配,j 都会因为不断=next[j],进而进入初始化逻辑, 赋值为 0.

最终完整的 kmp 代码如下:

	vector<int> getNext(string needle){
		vector<int> result(needle.length());
		
		int i = 0,j=-1;
		result[0] = -1;
		while(i < needle.length() - 1){
			if (j == -1 || needle[i] == needle[j]){
				i++;
				j++;
				result[i] = j;
			}else{
				j = result[j];
			}
		}
		
		return result;
	}
	
	int strStr(string haystack, string needle) {
		if (needle.empty()){
			return 0;
		}
		
		if (needle.length() > haystack.length()){
			return -1;
		}
		vector<int> next = getNext(needle);
		
		int i = 0;
		
		while(i<haystack.length()){
			if (haystack.length() - i < needle.length()){
				return -1;
			}
			int j = 0;
			for (j =0 ;j<needle.length();j++){
				if (haystack[i+j] != needle[j])
					break;
			}
			
			if (j >= needle.length()){
				return i;
			}
			i += j - next[j];
			
			//检查已经匹配的部分是否存在共同前后缀
			
		}
		return -1;
	}

最后,祝大家中秋快乐。

Build a cross-platform video player in 7 days

It’s been quite a while that I want to write some articles about FFmpeg. Learning and using FF in production is always a painful process for me and I believe the others probably have the same feelings.

FFmpeg is powerful tool and contains rich set of features for video codec and editing. It’s hard to explain all features of it, in this series of articles. I will demonstrate how to write an simple crossplatform player based on FFmpeg, which will demonstrate the major usage of FFmpeg.

I will choose iOS as dev platform just for convince (I really hate to write JNI interface). The code will available for Android after few cross-compile work.

  1. Day 1: Setup workspace (This article)

Prepare FFmpeg for iOS

Goal: Understand how to build FFmpeg for iOS

FFmpeg is written in C, so it’s natually available for multiple plarform if we can find suitable cross-compiler, e.g. Xcode for iOS, NDK for Android.

After the suitable tool installed, we still need to do many many configuration before we can cross build the FF. Fortunately there are plenty pre-configure build scripts for both iOS&Android in github, which will save bunch of time for us.

Locate the folder you want to save the scripts. Then run the following commands one by one.

git clone git@github.com:kewlbear/FFmpeg-iOS-build-script.git
cd FFmpeg-iOS-build-script/
./build-ffmpeg.sh armv7 x86_64

add x86_64 to enable iPhone simulator debugging.

Scripts will automatically download the necessary component such as FFmpeg sources. After the building process finished, you can find the binary lib file and header file in ./FFmpeg-iOS/

Player Project Setup

One thing need to be know for cross-platform project is it’s impossible to erase all platform-specific codes, it is necessary for porting our player to real world mobile project. We should place all platform-specific code together to keep core part of codes clean.

Step 1. Based on previous analysis, we slightly changed the folder to the following version

  • NKMPlayer/
    • libs/
      • include/
      • libs/
    • platform/
      • iOS/
        • FFPlayground/
      • Android/
    • core/

FFPlayground is our test project for test our player library.

Step 2. Create two files NKMPlayer.cpp NKMPlayer.h under core/ Step 3. Add some code for configuration testing. There is only 1 member function currently, used to get ffmpeg build configuration string.

NKMPlayer.h

#ifndef _NKMPlayer_h
#define _NKMPlayer_h
#include<string>

class NKMPlayer
{
    public:
    NKMPlayer();
    std::string ffBuildConfiguration(); 

};

#endif

NKMPlayer.cpp

#include "NKMPlayer.h"
extern "C" {
    #include "libavformat/avformat.h"
}

NKMPlayer::NKMPlayer()
{

}

std::string NKMPlayer::ffBuildConfiguration()
{
    av_register_all();
    char s[1000] = {0};
    sprintf(s, "%s\n", avcodec_configuration());
    std::string result(s);
    return s;
}

Step 4. Build it! Firstly we need to create a plain text file named CMakeLists.txt under path src

# 1.
project(NKMPlayer)
cmake_minimum_required(VERSION 3.4.1)

set(CMAKE_CXX_STANDARD 11)
set(MOBILE_PLATFORM ON)
set(DEVICE_FAMILY "1")

# 2. 
FILE(GLOB SRC_FILES ${CMAKE_SOURCE_DIR}/core/*.*)

# 3.
macro(ADD_FRAMEWORK PROJECT_NAME FRAMEWORK_NAME)
    find_library(
        "FRAMEWORK_${FRAMEWORK_NAME}"
        NAMES ${FRAMEWORK_NAME}
        PATHS ${CMAKE_OSX_SYSROOT}/System/Library
        PATH_SUFFIXES Frameworks
        NO_DEFAULT_PATH
    )
    if(${FRAMEWORK_${FRAMEWORK_NAME}} STREQUAL FRAMEWORK_${FRAMEWORK_NAME}-NOTFOUND)
        MESSAGE(ERROR ": Framework ${FRAMEWORK_NAME} not found")
    else()
        TARGET_LINK_LIBRARIES(${PROJECT_NAME} ${FRAMEWORK_${FRAMEWORK_NAME}})
        message(STATUS "find framework: ${FRAMEWORK_${FRAMEWORK_NAME}}")
    endif()
endmacro(ADD_FRAMEWORK)


#ADD_DEFINE(IOS)
set(DEPLOYMENT_TARGET 8.0) 

# 4.
include_directories(
    ${CMAKE_SOURCE_DIR}/../libs/include/
    ${CMAKE_SOURCE_DIR}/core
)

# 5.
link_directories(
    ${CMAKE_SOURCE_DIR}/../libs/lib/
)

# 6. 
add_library(NKMPlayer SHARED ${SRC_FILES})
ADD_FRAMEWORK(NKMPlayer VideoToolBox)
ADD_FRAMEWORK(NKMPlayer CoreMedia)
ADD_FRAMEWORK(NKMPlayer CoreVideo)
ADD_FRAMEWORK(NKMPlayer CoreFoundation)
ADD_FRAMEWORK(NKMPlayer Security)
ADD_FRAMEWORK(NKMPlayer AudioToolBox)

# 7.
target_link_libraries(NKMPlayer
"-Wl" 
avformat avcodec avdevice avfilter avutil swresample swscale
z bz2 iconv
)

Explanation:

#1. Declare our project name

#2. Tell cmake where to find our source files.

#3. Add a “CMake Function” —— ADD_FRAMEWORK which we will use it to find&link iOS framework to our project.

#4. Header search path.

#5. Library search path.

#6. Declare our target: a shared Library with name: NKMPlayer, and associate dependency framework.

#7. Finally, associate static dependency library.

Step 5. Are we cool now? Not yet. We need to tell cmake what toolchain we want.

Generally speaking, The term “toolchain” means a set of build tools in cross-compile field which provided by target operation system company, such as we have android toolchain from NDK and iOS toolchain from Xcode. It usually includes compiler, linker, archiever, etc.

CMake has a option -DCMAKE\_TOOLCHAIN\_FILE allow us easily import toolchain configuration from a file. There are many well wrote iOS toolchain file available in github, e.g. https://github.com/sheldonth/ios-cmake. We can directly use them in out project.

Create a plain text file with name ios.cmake under src

ios.cmake

 set (CMAKE_SYSTEM_NAME Darwin)
set (CMAKE_SYSTEM_VERSION 1)
set (UNIX True)
set (APPLE True)
set (IOS True)

# Required as of cmake 2.8.10
set (CMAKE_OSX_DEPLOYMENT_TARGET "" CACHE STRING "Force unset of the deployment target for iOS" FORCE)

# Determine the cmake host system version so we know where to find the iOS SDKs
find_program (CMAKE_UNAME uname /bin /usr/bin /usr/local/bin)
if (CMAKE_UNAME)
	exec_program(uname ARGS -r OUTPUT_VARIABLE CMAKE_HOST_SYSTEM_VERSION)
	string (REGEX REPLACE "^([0-9]+)\\.([0-9]+).*$" "\\1" DARWIN_MAJOR_VERSION "${CMAKE_HOST_SYSTEM_VERSION}")
endif (CMAKE_UNAME)

# Force the compilers to Clang for iOS
include (CMakeForceCompiler)
#set(CMAKE_C_COMPILER /usr/bin/clang Clang)
#set(CMAKE_CXX_COMPILER /usr/bin/clang++ Clang)
set(CMAKE_AR ar CACHE FILEPATH "" FORCE)

# Skip the platform compiler checks for cross compiling
set (CMAKE_CXX_COMPILER_WORKS TRUE)
set (CMAKE_C_COMPILER_WORKS TRUE)

# All iOS/Darwin specific settings - some may be redundant
set (CMAKE_SHARED_LIBRARY_PREFIX "lib")
set (CMAKE_SHARED_LIBRARY_SUFFIX ".dylib")
set (CMAKE_SHARED_MODULE_PREFIX "lib")
set (CMAKE_SHARED_MODULE_SUFFIX ".so")
set (CMAKE_MODULE_EXISTS 1)
set (CMAKE_DL_LIBS "")

set (CMAKE_C_OSX_COMPATIBILITY_VERSION_FLAG "-compatibility_version ")
set (CMAKE_C_OSX_CURRENT_VERSION_FLAG "-current_version ")
set (CMAKE_CXX_OSX_COMPATIBILITY_VERSION_FLAG "${CMAKE_C_OSX_COMPATIBILITY_VERSION_FLAG}")
set (CMAKE_CXX_OSX_CURRENT_VERSION_FLAG "${CMAKE_C_OSX_CURRENT_VERSION_FLAG}")

set (CMAKE_C_FLAGS_INIT "")
set (CMAKE_CXX_FLAGS_INIT "")

set (CMAKE_C_LINK_FLAGS "-Wl,-search_paths_first ${CMAKE_C_LINK_FLAGS}")
set (CMAKE_CXX_LINK_FLAGS "-Wl,-search_paths_first ${CMAKE_CXX_LINK_FLAGS}")

set (CMAKE_PLATFORM_HAS_INSTALLNAME 1)
set (CMAKE_SHARED_LIBRARY_CREATE_C_FLAGS "-dynamiclib -headerpad_max_install_names")
set (CMAKE_SHARED_MODULE_CREATE_C_FLAGS "-bundle -headerpad_max_install_names")
set (CMAKE_SHARED_MODULE_LOADER_C_FLAG "-Wl,-bundle_loader,")
set (CMAKE_SHARED_MODULE_LOADER_CXX_FLAG "-Wl,-bundle_loader,")
set (CMAKE_FIND_LIBRARY_SUFFIXES ".dylib" ".so" ".a")

# hack: if a new cmake (which uses CMAKE_INSTALL_NAME_TOOL) runs on an old build tree
# (where install_name_tool was hardcoded) and where CMAKE_INSTALL_NAME_TOOL isn't in the cache
# and still cmake didn't fail in CMakeFindBinUtils.cmake (because it isn't rerun)
# hardcode CMAKE_INSTALL_NAME_TOOL here to install_name_tool, so it behaves as it did before, Alex
if (NOT DEFINED CMAKE_INSTALL_NAME_TOOL)
	find_program(CMAKE_INSTALL_NAME_TOOL install_name_tool)
endif (NOT DEFINED CMAKE_INSTALL_NAME_TOOL)

# Setup iOS platform unless specified manually with IOS_PLATFORM
if (NOT DEFINED IOS_PLATFORM)
	set (IOS_PLATFORM "OS")
endif (NOT DEFINED IOS_PLATFORM)
set (IOS_PLATFORM ${IOS_PLATFORM} CACHE STRING "Type of iOS Platform")

# Setup building for arm64 or not
if (NOT DEFINED BUILD_ARM64)
    set (BUILD_ARM64 true)
endif (NOT DEFINED BUILD_ARM64)
set (BUILD_ARM64 ${BUILD_ARM64} CACHE STRING "Build arm64 arch or not")

# Check the platform selection and setup for developer root
if (IOS_PLATFORM STREQUAL "OS")
	set (IOS_PLATFORM_LOCATION "iPhoneOS.platform")

	# This causes the installers to properly locate the output libraries
	set (CMAKE_XCODE_EFFECTIVE_PLATFORMS "-iphoneos")
elseif (IOS_PLATFORM STREQUAL "SIMULATOR")
    set (SIMULATOR true)
	set (IOS_PLATFORM_LOCATION "iPhoneSimulator.platform")

	# This causes the installers to properly locate the output libraries
	set (CMAKE_XCODE_EFFECTIVE_PLATFORMS "-iphonesimulator")
elseif (IOS_PLATFORM STREQUAL "SIMULATOR64")
    set (SIMULATOR true)
	set (IOS_PLATFORM_LOCATION "iPhoneSimulator.platform")

	# This causes the installers to properly locate the output libraries
	set (CMAKE_XCODE_EFFECTIVE_PLATFORMS "-iphonesimulator")
else ()
	message (FATAL_ERROR "Unsupported IOS_PLATFORM value selected. Please choose OS or SIMULATOR")
endif ()

# Setup iOS developer location unless specified manually with CMAKE_IOS_DEVELOPER_ROOT
# Note Xcode 4.3 changed the installation location, choose the most recent one available
set (XCODE_POST_43_ROOT "/Applications/Xcode.app/Contents/Developer/Platforms/${IOS_PLATFORM_LOCATION}/Developer")
set (XCODE_PRE_43_ROOT "/Developer/Platforms/${IOS_PLATFORM_LOCATION}/Developer")
if (NOT DEFINED CMAKE_IOS_DEVELOPER_ROOT)
	if (EXISTS ${XCODE_POST_43_ROOT})
		set (CMAKE_IOS_DEVELOPER_ROOT ${XCODE_POST_43_ROOT})
	elseif(EXISTS ${XCODE_PRE_43_ROOT})
		set (CMAKE_IOS_DEVELOPER_ROOT ${XCODE_PRE_43_ROOT})
	endif (EXISTS ${XCODE_POST_43_ROOT})
endif (NOT DEFINED CMAKE_IOS_DEVELOPER_ROOT)
set (CMAKE_IOS_DEVELOPER_ROOT ${CMAKE_IOS_DEVELOPER_ROOT} CACHE PATH "Location of iOS Platform")

# Find and use the most recent iOS sdk unless specified manually with CMAKE_IOS_SDK_ROOT
if (NOT DEFINED CMAKE_IOS_SDK_ROOT)
	file (GLOB _CMAKE_IOS_SDKS "${CMAKE_IOS_DEVELOPER_ROOT}/SDKs/*")
	if (_CMAKE_IOS_SDKS) 
		list (SORT _CMAKE_IOS_SDKS)
		list (REVERSE _CMAKE_IOS_SDKS)
		list (GET _CMAKE_IOS_SDKS 0 CMAKE_IOS_SDK_ROOT)
	else (_CMAKE_IOS_SDKS)
		message (FATAL_ERROR "No iOS SDK's found in default search path ${CMAKE_IOS_DEVELOPER_ROOT}. Manually set CMAKE_IOS_SDK_ROOT or install the iOS SDK.")
	endif (_CMAKE_IOS_SDKS)
	message (STATUS "Toolchain using default iOS SDK: ${CMAKE_IOS_SDK_ROOT}")
endif (NOT DEFINED CMAKE_IOS_SDK_ROOT)
set (CMAKE_IOS_SDK_ROOT ${CMAKE_IOS_SDK_ROOT} CACHE PATH "Location of the selected iOS SDK")

# Set the sysroot default to the most recent SDK
set (CMAKE_OSX_SYSROOT ${CMAKE_IOS_SDK_ROOT} CACHE PATH "Sysroot used for iOS support")

# set the architecture for iOS 
if (IOS_PLATFORM STREQUAL "OS")
    set (IOS_ARCH armv7 armv7s arm64)
elseif (IOS_PLATFORM STREQUAL "SIMULATOR")
    set (IOS_ARCH i386)
elseif (IOS_PLATFORM STREQUAL "SIMULATOR64")
    set (IOS_ARCH x86_64)
endif ()

set (CMAKE_OSX_ARCHITECTURES ${IOS_ARCH} CACHE string  "Build architecture for iOS")

# Set the find root to the iOS developer roots and to user defined paths
set (CMAKE_FIND_ROOT_PATH ${CMAKE_IOS_DEVELOPER_ROOT} ${CMAKE_IOS_SDK_ROOT} ${CMAKE_PREFIX_PATH} CACHE string  "iOS find search path root")

# default to searching for frameworks first
set (CMAKE_FIND_FRAMEWORK FIRST)

# set up the default search directories for frameworks
set (CMAKE_SYSTEM_FRAMEWORK_PATH
	${CMAKE_IOS_SDK_ROOT}/System/Library/Frameworks
	${CMAKE_IOS_SDK_ROOT}/System/Library/PrivateFrameworks
	${CMAKE_IOS_SDK_ROOT}/Developer/Library/Frameworks
)

# only search the iOS sdks, not the remainder of the host filesystem
set (CMAKE_FIND_ROOT_PATH_MODE_PROGRAM ONLY)
set (CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set (CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)


# This little macro lets you set any XCode specific property
macro (set_xcode_property TARGET XCODE_PROPERTY XCODE_VALUE)
	set_property (TARGET ${TARGET} PROPERTY XCODE_ATTRIBUTE_${XCODE_PROPERTY} ${XCODE_VALUE})
endmacro (set_xcode_property)


# This macro lets you find executable programs on the host system
macro (find_host_package)
	set (CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
	set (CMAKE_FIND_ROOT_PATH_MODE_LIBRARY NEVER)
	set (CMAKE_FIND_ROOT_PATH_MODE_INCLUDE NEVER)
	set (IOS FALSE)

	find_package(${ARGN})

	set (IOS TRUE)
	set (CMAKE_FIND_ROOT_PATH_MODE_PROGRAM ONLY)
	set (CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
	set (CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
endmacro (find_host_package)

Step 6. Time to do real build! Create a folder with name build under NKMPlayer, same level with src, then exec the following commands.

cd build/
cmake -DCMAKE_TOOLCHAIN_FILE=./ios.cmake -DIOS_PLATFORM=SIMULATOR64 ../src -Wno-dev

You’ll see the library file libNKMPlayer.dylib in build if everything goes well. Wow, our hello-world library is ready to launch !

Test Library in Simulator

1, Create a Single View App iOS project in Xcode,and save it in NKMPlayer/platform/iOS/FFPlayground (FFPlaygruond is the project name) 2, Add libNKMPlayer.dylib in build phrase tab. 3, Add some code to ViewController.m, make it looks like following

#import "ViewController.h"
#include "NKMPlayer.h"
#include<iostream>
using namespace std;
@interface ViewController ()

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    NKMPlayer player;
    cout<<player.ffBuildConfiguration()<<endl;
    // Do any additional setup after loading the view.
}

4, Press CMD + R to run the program, you’ll see the ffmpeg build configuration be printed in console, which will looks like:

--target-os=darwin --arch=x86_64 --cc='xcrun -sdk iphonesimulator clang' --as='gas-preprocessor.pl -- xcrun -sdk iphonesimulator clang' --enable-cross-compile --disable-debug --disable-programs --disable-doc --enable-pic --extra-cflags='-arch x86_64 -mios-simulator-version-min=8.0' --extra-ldflags='-arch x86_64 -mios-simulator-version-min=8.0' --prefix=/Users/ali/Documents/projects/github/FFmpeg-iOS-build-script/thin/x86_64

It’s great right? Looks lilke our player lib works well on the iOS.

Conclusion

In this article, we finished following tasks:

  1. Cross build a ffmpeg binary for iOS
  2. Configure cmake-based cross platform library project with FFmpeg
  3. Test our library in iPhone simulator.

We made our first step, we are going to implement basic video decode for our player, to explore the magnificent world of FFmpeg in next article.

给程序员的一些持续学习的建议

受老司机编辑世杰的邀请,分享一些我对于工程师持续学习的建议。

一. 成长经历

我是莲叔。2010 年本科毕业进入中科院读研,12 年开始创业,16 年结束创业进入 uc。整体技术栈是后台为主-》客户端为主。

二. 近年学习经历

本质上,学习的目的就是要实现能力的扩展。我从 12 年开始主要就是在做 ios 开发,其中能力拓展主要来自于两个方面:

1. 工作中遇到的挑战:

12 年学习了 python 的后台开发

17 年学习了 Weex/JS以及一些大前端的技术

18 年学习了视频播放编辑的相关技术

2. 自己主动寻求的能力拓展

15 年初学习了Swift

15 年底学习了Haskell 和函数式编程。

18 年学习了机器学习和深度学习

从效果上来说,为了应对工作中的挑战毫无疑问是更扎实的,但如果找到合适的方法,自己主动寻求的学习也能获得不错的效果。

三. 如何学习

工作中的学习

如果学习的目的是为了应对工作中的挑战,首先要恭喜你,大概率你这次学习会是有效果、有一定成果的,因为有明确的目标。那如何最大程度发挥这类学习的收益呢? 这里提一个关键词:

体系。

应对挑战的学习最忌讳的就是浅尝辄止,如果简单以完成任务去学习,那往往最后的效果是很一般的。要以不仅要完成任务,还要完成的很好,整个过程能够给别人一种,你“终结”了这个问题。什么叫终结呢? 就是你在解决一个问题的过程已经做得十分体系,换一个人肯定不会做得更好。

举个栗子,你们的产品视频打开比较慢,你负责来优化打开的速度。那大概可能会有以下的几个阶段:

1 原先用 avplayer,换成 ijk 试试

2 实现预加载一部分数据,省去数据下载的耗时

做完了这两步,往往体验就能有了不少的提升,但如果在这就停了,那其实反观这个过程,我们什么都没学到。那继续深入尝试一下:

首先可以,分析预加载的命中率,尝试提升命中率

3 比如尝试预测用户的点击位置。来优先预加载用户最可能点击的 item

这里,你可能可以学习到一些基本的机器学习知识。

4 比如尝试精细化预加载数据的大小,那就需要计算一个视频需要下多少数据可以开播?对于 mp4 文件来说,这个大小就是 MOOV 块和头两帧的数据,MOOV 的大小和视频时长有关,而视频每帧的大小则和视频的码率以及编码方式有关。所以如何计算这个大小,也有不少的知识需要准备

这里,你会学到基本的视频编解码以及常见的封装格式的知识

除了命中率,很多耗时往往也出现在播放器创建和渲染的等待,那是不是可以

5 实现播放器预创建+后台预渲染,这样在用户点击播放,直接把渲染好的 layer swap 到前台,进一步提升速度。

这里,你可以学到播放器解码完后的数据是怎么画到屏幕的这个流程,

除了在细节上尝试做得更加极致,另外的方向是把整个过程做得更体系。落到这个问题,我们要回答:这次优化完后,下次如果又变慢了,我们能知道是哪里导致的吗?

6 尝试分析每一次开播的全链路的耗时,创建播放器->layout 播放器 view->下载->解封装->解码->渲染,并建立相应的各阶段耗时监控。这样未来的耗时可以通过报表直观的看到是哪个环节出了问题。

这里,你一方面对于整个播放器的内部逻辑会有相应的了解,另一方面也能收获如何“终结”一个事情的能力。

自我驱动的学习

业余时间的学习往往都来自于三个动机:

  1. 兴趣使然;
  2. 工作焦虑,希望寻求业余的成长环节焦虑
  3. 谋求转行

无论是哪一种,业余时间的学习都比应对工作挑战的学习要难得多。核心原因在于缺乏明确的目标和节奏。漫无目的的看书 or 练习收到的成效往往很一般。另外自我驱动的学习还比较容易遇到一个问题:容易因为一些阶段性的成果而自满。

这里有两个建议: 1 以终为始,项目驱动

项目驱动的意思是你最好以一个具体的项目作为目标来驱动你的学习,而以终为始的意思是,你最好在你还没有开始学习的时候,就先想好你的具体目标。然后再围绕自己的目标去有针对性的展开学习。

比如我当时学习 Haskell 的时候,就立了第一个小目标:写一个 schema 解释器。所以我在之后一直围绕这个目标,重点学 State monad 和 IO, 以及 parser combinator,适当减少了一些 applicative 方向的应用的学习。一定要明白,业余时间的学习是不可能很体系很全的,但至少可以通过几个明确的项目来让自己具备一战的能力。

2 找到大牛聚集的社区,看大牛们都在做什么样的项目

拿机器学习和函数式编程来说,知乎是个不错的地方,很多相关的专业人才都活跃在知乎,通过 fo 他们的问题和回答,可以很容易发现差距,并根据相关的情况制定下一个阶段的目标。

四. 职业规划

职业规划是一个几乎所有人都会面临的问题,但俗一点来看,更多的人遇到的命题更多的是 “XX 很火,我该不该转行”

具体来看,比如近几年都说 iOS 找不到工作了,要不要转后台/AI/区块链/管理。这里我给的意见是:不该。

这并不代表我们要两耳不闻窗外事,而是大多数人对于一个职业所付出的耐心实在太低,在 iOS 上遇到的瓶颈,在其他行业一样会遇到。往往我们认为一个领域没什么事情好干的时候,其实并不是真的没事儿干,而是自己思考不足。这种感觉就像很多人到了 XX 城市的城中村里吃了份蛋炒饭,完了就说 XX 城市很垃圾,脏乱差,但如果真正去 XX 城市最高的大厦里吃着烤鹅肝喝着酒看着城市的日落,我们还会觉得脏乱差吗?

可以试一下这样的一个思维训练:

  1. 我现在在做的事情,换一个大佬来,能做的比我更好吗?
  2. 如果可以,那最可能是在哪方面做的比我好?
  3. 如果知道哪方面,那现在的我可以去做的更好吗?

如果想不出答案,就多和别人,和主管,和大牛同事讨论这几个问题,往往都能发现即便在 iOS 的日常业务有也蕴含着无限的可能性。

另外一个问题是转管理,其实互联网和传统行业不一样,程序员都是 highly motivated 的人,换句话说其实是不用怎么管理的。所以程序员转管理一般是伪命题,你可以当 leader,当 leader 的职责可不是管理,管理顶多占 20%,更多的是抢业务,技术专项的规划与执行、业务的赋能。这一些的基础都是需要丰富的一线作战经验,所以更多的时候不是转不转的问题,而是你到没到的问题。

综上,在职业规划上,我给的建议是:

  1. 不要轻易转行,优先尝试在自己优势领域开花,成长
  2. 35 岁以前不要思考要不要转管理

线性回归的数学推导

最近花时间认真学习了一下机器学习中的回归问题,虽然回归问题(线性回归+对率回归)在周志华的书里就那么几页,但其实书里对很多公式的推导省略了大量的细节,这次秉着较真的态度看了一下,发现名堂其实不少。写篇文章总结一下。

1. 机器学什么

了解过机器学习都应该知道,机器学习的本质就是给出一个包含n条记录的记录集合\(\begin{align*}\mathbf{D}=\{(\mathbf{x_{1}},y_{1}),(\mathbf{x_{2}},y_{2}),...,(\mathbf{x_{n}},y_{n}) \} \end{align*}\),其中某一条记录\(\begin{align*}\mathbf{x_{i}} \end{align*}\)表示第i条记录的输入,\(\begin{align*} y_{i} \end{align*}\)代表第i条记录的输出标记。而单个\(\begin{align*} x_{i} \end{align*}\)一般为向量,表示每条记录的属性集合, \(\begin{align*} \mathbf{x_{i}} = \{x_{i1},x_{i2},...,x_{id}\} \end{align*}\) 表示对于每一条记录的x,包含d个属性。

机器学习的任务都是通过数据集\(\begin{align*} \mathbf{D} \end{align*}\), 学习得一个\(\begin{align*} f \end{align*}\) ,使得\(\begin{align*} f(x_{i}) \approx y_{i} \end{align*}\)。

2. 线性模型

这个\(\begin{align*} f \end{align*}\)长什么样,一般被称作“模型”,线性模型基本是最简单的一种:将属性加权组合。给定一个包含d个属性的记录\(\begin{align*}\mathbf{x}=\{x_{1}; x_{2}; x_{3}...x_{d}\} \end{align*}\),线性模型尝试学得:

\[\begin{align*} \mathbf{f(x)} = w_{1}x_{1} + w_{2}x_{2} + ... + w_{d}x_{d} + b\end{align*}\]

其中,\(\begin{align*} \mathbf{w} = \{w_{1},w_{2},...w_{d}\} \end{align*}\) 为各个属性的权重,b为偏移(可以类比直线方程,\(\begin{align*} y = kx + b \end{align*}\))。

这里所说的学习,就是要通过数据集\(\begin{align*} D \end{align*}\) 来确定 \(\begin{align*} \mathbf{w} 和 b \end{align*}\)。把w和x写为向量形式,则线性模型可以表示为:

\[\begin{align*} \mathbf{f(x)} = \mathbf{w^{T}x} + b \end{align*}\]

\(\begin{align*} \mathbf{w} \end{align*}\) 为什么要转置?机器学习中所有的向量默认都是列向量,所以\(\begin{align*} \mathbf{w} \end{align*}\)需要转置成行向量才可以和\(\begin{align*}\mathbf{x} \end{align*}\)相乘。 这里如果懵逼的话,建议复习一下矩阵乘法的规则。

\[\begin{align*} \end{align*}\]

3. 线性回归

一句话解释,线性回归就是线性模型的学习过程。在给定输入数据集\(\begin{align*} \mathbf{D} \end{align*}\)的基础上, 找到能够满足要求的 \(\begin{align*} \mathbf{w}和b \end{align*}\), 这个过程,就是线性回归。

那要怎么求解线性回归呢?不同的老师会用不同的方法,比如周志华会先从一元变量来举例(即假设所有记录都只有一个属性),进而扩展到多元的场景。这里介绍林轩田的求解思路,更nice一些。

3.1 等价变换

这里我们把\(\begin{align*} b \end{align*}\) 记做 \(\begin{align*} w_{0} \end{align*}\),再设 \(\begin{align*} x_{0}=1 \end{align*}\)。这样,上面的线性模型可以等价改写为:

\[\begin{align*} \mathbf{f(x)} = w_{0}x_{0} + w_{1}x_{1} + w_{2}x_{2} + ... + w_{d}x_{d} \end{align*}\]

等价为向量模式(因为新引入了\(\begin{align*} w_{0} \end{align*}\)和 \(\begin{align*} x_{0} \end{align*}\),故用大写表示):

\[\begin{align*} \mathbf{f(x)} = W^{T}X \end{align*}\]

3.2 误差衡量

在考虑如何找到最好的\(\begin{align*} W \end{align*}\)之前,我们先考虑我们如何衡量一个\(\begin{align*} W \end{align*}\)的好坏。在机器学习中,最常见的误差衡量标准就是均方差(Mean Square Erro,MSE)。给定一个\(\begin{align*} W \end{align*}\)时,在样本集\(\begin{align*} \mathbf{D} \end{align*}\)的n个样本的MSE为:

\[\begin{align*} E(W) = \frac{1}{n}\sum_{i=1}^{n}(W^{T}X_{i} - y{i})^{2} \end{align*}\]

这里的\(\begin{align*} X_{i} \end{align*}\)是向量,代表一条记录的输入,\(\begin{align*} y_{i} \end{align*}\)代表对应的样本标签。

为了方便后面的推导,交换一下X和w的位置,所以变成:

\[\begin{align*} E(W) = \frac{1}{n}\sum_{i=1}^{n}(X_{i}^{T}W - y_{i})^{2} \end{align*}\]

如果不明白为毛可以交换,你可以展开一下,就会发现是等价的;

这里我们看到式子里有个求和的符号,看着比较讨厌(不方便后续推导)。能不能干掉呢?观察一下求和内部的式子,是一堆平方的和。数学中有不少的概念最终落地下来是平方和的形式,一个直接的例子就是向量的模。比如一个N维向量\(\begin{align*} \vec a \end{align*}\) 的模 \(\begin{align*} \|\vec a\| = \sqrt[2]{a_{1}^{2} + a_{2}^{2} + a_{3}^{2}...+ a_{N}^{2} } \end{align*}\), 同理,上述的E(W)可以写成:

\[\begin{align*} E(W) = \frac{1}{n}\begin{Vmatrix} X_{1}^{T}W - y_{1} \\ X_{2}^{T}W - y_{2} \\ ... \\ X_{n}^{T}W - y_{n} \end{Vmatrix} ^{2} \end{align*}\]

可等价变换为:

\[\begin{align*} E(W) = \frac{1}{n}\begin{Vmatrix} \begin{pmatrix} X_{1}^{T} \\ X_{2}^{T} \\ ... \\ X_{n}^{T} \end{pmatrix}W - \begin{pmatrix} y_{1} \\ y_{2} \\ ... \\ y_{n} \end{pmatrix} \end{Vmatrix} ^{2} \end{align*}\]

将X 和 y表示为向量形式,于是有:

\[\begin{align*} E(W) = \frac{1}{n} \begin{Vmatrix} \mathbf{X^{T}}W - \mathbf{y} \end{Vmatrix} ^{2} \end{align*}\]

3.3 误差最小化

当我们得出E(W)的表达式之后,整个线性回归的过程就变成了找到一个W,使E(W)最小化。我们都知道求极值最直接的方法就是对其函数求导,导数为0的点就是极值点。再通过分析左右导数的正负情况即可得到能够使E(W)最小的W。

首先对E(W) 求导可得:

\[\begin{align*} \frac{\partial E(W)}{\partial W} = \frac{\partial \frac{1}{n}( \mathbf{W^{T}{X^{T}XW} - 2W^{T}X^{T}y + y^{T}y} )}{\partial W} \end{align*}\]

这里先用了一个简单的公式把上式展开 \(\begin{align*} (a+b)^{2} = a^{2} + 2ab + b^{2} \end{align*}\)

在上式中,X和y都是标量(只有W是变量),为了理解方便,我们假设 \(\begin{align*} A = X^{T}X, b = X^{T}y, c = y^{T}y \end{align*}\) , 于是上式可以化简为:

\[\begin{align*} \frac{\partial E(W)}{\partial W} = \frac{\partial \frac{1}{n}( \mathbf{W^{T}AW - 2W^{T}b + c} )}{\partial W} \end{align*}\]

现在的问题来了,W是一个d维向量,代表每个输入记录的d个特征值。我们该如何对向量求导呢?先假设w,a,b,c是普通的标量,则有:

\[\begin{align*} \frac{\partial E(w)}{\partial w} = \frac{\partial \frac{1}{n}( aw^{2} - 2wb + c )}{\partial w} = \frac{1}{n}(2aw - 2b) \end{align*}\]

标量情况的确是很简单,那向量情况呢?一般来说对矩阵求导都比较复杂,具体有兴趣的同学可以参考MIT的Matrix cookbook。原理其实比较简单,无非就是分别针对向量的各个分量求偏导,最终的结果组成的向量就是求导的结果。但推导起来肯定都比较复杂。cookbook针对常见情况直接给出了公式,非常方便。

要求解上式的公式,主要会用到里面的两个公式:

公式(69): \(\begin{align*} \frac{\partial \mathbf{x}^{T}\mathbf{a}}{\partial \mathbf{x}} = \frac{\partial \mathbf{a}^{T}\mathbf{x}}{\partial \mathbf{x}} = \mathbf{a} \end{align*}\)

公式(81): \(\begin{align*} \frac{\partial \mathbf{x}^{T}B\mathbf{x}}{\partial \mathbf{x}} = (B + B^{T})\mathbf{x} \end{align*}\)

将(69)和(81)代入上面的向量版微分公式,可以得到:

\[\begin{align*} \frac{\partial E(W)}{\partial W} = \frac{\partial \frac{1}{n}( \mathbf{W^{T}AW - 2W^{T}b + c} )}{\partial W} = \frac{1}{n}((A + A^{T})W - 2b) = \frac{1}{n}(2AW - 2b) \\ = \frac{2}{n}(X^{T}XW - 2X^{T}y) \end{align*}\]

因为\(\begin{align*} A = X^{T}X \end{align*}\), 所以 \(\begin{align*} A+A^{T} = 2A \end{align*}\)

可以看到形式上,求导结果和标量版的一模一样。这或许就是线性代数美的地方。

令E(W)=0,可以得到此时W的取值为:

\[\begin{align*} W = (X^{T}X)^{-1}X^{T}y \end{align*}\]

上式也叫W的闭式解(Closed-form solution), 代表是从公式精准的推导出的结果,而不是靠一些数值优化方法得出。机器学习中,由于n的数量一般都>d+1(d为属性的个数),所以\(\begin{align*} X^{T}X \end{align*}\) 一般都是可逆的。

参考资料

  1. 林轩田的公开课;
  2. Matrix cookbook;

开篇文章,记录常见的latex语法

  • \mathbf{X} 加粗X
  • \X_{d} X下标为d
  • \X^{d} X上标为d
  • { } 因为{}是latex的语法,所以要显示{}需要使用转义符号
  • \approx, 约等于
  • \frac{分子}{分母},分数

妈的太多了,还是直接看链接吧:

完整版本的可以参考这里

矩阵的可以参考这里

第一次体验飞机上的wifi

下午临时接到通知去优酷出差,晚上就一脸懵逼的登上了飞机。

南航的大飞机感觉更新换代很快,娱乐系统比想象中的好,更牛逼的是居然有wifi!第一次在高空中上网有一种奇怪的体验。

弄了个博客,发帖庆祝一下

不记得这是我第几个博客,虽然根据之前的经验,这很有可能是我又一次心血来潮。不过冥冥之中我感觉很不一样,总觉得这次的心境和之前有了很大的不同。

或许真的到了,总是希望去记录点什么的年龄。

写于2018年1月29日,办公室