卷積神經(jīng)網(wǎng)絡(luò)在物聯(lián)網(wǎng)場景中的應(yīng)用初探

概述

機器學(xué)習(xí)發(fā)展到今天,得益于數(shù)據(jù)量的增長知举、算力的豐富瞬沦、和深度神經(jīng)網(wǎng)絡(luò)技術(shù)的不斷創(chuàng)新和廣泛應(yīng)用,像計算機視覺雇锡、自動控制逛钻、圖像識別、語音識別锰提、自然語言處理和音頻識別等領(lǐng)域曙痘,在最近幾年中芳悲,不斷迎來突破。這直接導(dǎo)致了AI技術(shù)的蓬勃發(fā)展屡江,像Alapha Go芭概、自動翻譯、自動駕駛汽車惩嘉、機器人技術(shù)罢洲、無人機技術(shù)等前沿領(lǐng)域,不斷涌現(xiàn)出新的“黑科技”文黎,持續(xù)吸引著大眾的眼球惹苗,成為資本爭相追捧的寵兒。

針對最近物聯(lián)網(wǎng)實驗室關(guān)心的幾個課題耸峭,我們嘗試通過機器學(xué)習(xí)的手段桩蓉,借助圖像識別技術(shù)來解決一些自動分類和預(yù)警的問題,這些業(yè)務(wù)場景包括:

  • 火情識別

  • 高塔識別

希望通過解決這些問題劳闹,對機器學(xué)習(xí)在物聯(lián)網(wǎng)領(lǐng)域中的應(yīng)用進行一些探索院究,找到一些好的方案,在實際業(yè)務(wù)中提供幫助本涕。

本文先介紹卷積神經(jīng)網(wǎng)絡(luò)的基本知識业汰,在理解卷積神經(jīng)網(wǎng)絡(luò)的基礎(chǔ)上,我們使用keras搭建起一個卷積神經(jīng)網(wǎng)絡(luò)菩颖,使用事先搜集的樣本數(shù)據(jù)對網(wǎng)絡(luò)進行訓(xùn)練样漆,得到一個圖像識別模型,并測試模型的準(zhǔn)確性晦闰。

神經(jīng)網(wǎng)絡(luò)(Neural Network)

神經(jīng)網(wǎng)絡(luò)技術(shù)起源于上世紀(jì)五放祟、六十年代,當(dāng)時叫感知機(Perceptron)呻右,擁有輸入層跪妥、輸出層和一個隱藏層。輸入的特征向量通過隱藏層變換后到達(dá)輸出層声滥,在輸出層得到分類結(jié)果骗奖。

但感知機的擬合能力太弱了,對稍復(fù)雜一些的函數(shù)都無能為力醒串。隨著數(shù)學(xué)的發(fā)展,這個問題到了上世紀(jì)八十年代才被Rumelhart鄙皇、Williams芜赌、Hinton、LeCun等人發(fā)明的多層感知機(Multilayer Perceptron伴逸,MP)克服缠沈。多層感知機,就是有多個隱藏層的感知機,它使用Sigmoid或Tanh等連續(xù)函數(shù)模擬神經(jīng)元對激勵的響應(yīng)洲愤,在訓(xùn)練算法上則使用反向傳播(Back Propagation颓芭,BP)算法,使得各個神經(jīng)元的參數(shù)在訓(xùn)練過程中能夠不斷被調(diào)整優(yōu)化柬赐。

多層感知機亡问,其實就是神經(jīng)網(wǎng)絡(luò)(Neural Network,NN)了肛宋,它具備幾個很明顯的特點州藕。

  • 有一個輸入層

  • 有一個輸出層

  • 有一個或多個隱藏層,隱藏層越多酝陈,神經(jīng)網(wǎng)絡(luò)越深

神經(jīng)網(wǎng)絡(luò)(Neural Network)

神經(jīng)網(wǎng)絡(luò)的層數(shù)床玻,直接關(guān)系到它對現(xiàn)實問題的刻畫能力,換句話說就是足夠多的層數(shù)沉帮,足夠多的神經(jīng)元锈死,經(jīng)過合理的組織和訓(xùn)練,能夠擬合任意復(fù)雜的函數(shù)穆壕。理論證明待牵,有兩層隱藏層的神經(jīng)網(wǎng)絡(luò)可以無限逼近任意連續(xù)函數(shù)。隱藏層越多粱檀,擬合能力越強洲敢,擁有很多隱藏層的神經(jīng)網(wǎng)絡(luò),我們稱為深度神經(jīng)網(wǎng)絡(luò)(Deep Neural Network茄蚯,DNN)压彭。那有多少隱藏層算深?沒有統(tǒng)一答案渗常,視場景而定壮不,在語音識別中4層網(wǎng)絡(luò)就被認(rèn)為是較深的,而在自動駕駛皱碘、增強學(xué)習(xí)中20層以上的網(wǎng)絡(luò)屢見不鮮询一。

全連接神經(jīng)網(wǎng)絡(luò)應(yīng)用于圖像識別遇到的問題

在神經(jīng)網(wǎng)絡(luò)中每條邊都代表著神經(jīng)元之間的參數(shù),它在訓(xùn)練過程中將被不斷調(diào)整優(yōu)化癌椿。實際工作中健蕊,最常見的網(wǎng)絡(luò)是全連接神經(jīng)網(wǎng)絡(luò),每相鄰兩層之間下層神經(jīng)元和上層所有神經(jīng)元都能夠形成連接踢俄,帶來的潛在問題是參數(shù)數(shù)量的膨脹缩功。

考慮一個數(shù)字識別的神經(jīng)網(wǎng)絡(luò),假設(shè)輸入的是一幅像素為28×28的灰度圖像都办,那么輸入層的神經(jīng)元就有28×28=784個嫡锌,輸出層有0-9共10個神經(jīng)元虑稼,代表著圖像被識別為哪個數(shù)字,如果在中間只使用一層15個神經(jīng)元的隱藏層势木,那么參數(shù)就有 28×28×15×10=117600 個蛛倦。看下圖感受一下這些邊有多密集啦桌。

全連接神經(jīng)網(wǎng)絡(luò)

如果輸入圖像包含RGB三個色彩通道溯壶,那么參數(shù)的數(shù)量還要再乘以3,如果在隱藏層中再增加幾個神經(jīng)元震蒋,或再增加幾個隱藏層茸塞,那么需要訓(xùn)練的參數(shù)數(shù)量會繼續(xù)膨脹。

參數(shù)膨脹還會帶來另一個問題查剖,如果在訓(xùn)練過程中使用梯度下降钾虐,那么參數(shù)泛濫會導(dǎo)致解空間中出現(xiàn)大量局部最優(yōu)解,梯度下降將極易陷入局部最優(yōu)解中而導(dǎo)致訓(xùn)練過程早早收斂笋庄,很難收到好的效果效扫。

另一方面,每一層都全部接收了上一層的所有特征直砂,沒有結(jié)合圖像固有的模式提取出一些關(guān)鍵特征菌仁,這很容易走向過擬合,使模型失去泛化能力静暂。

總結(jié)起來济丘,全連接神經(jīng)網(wǎng)絡(luò)在處理圖像識別問題時有如下幾個弊端:

  • 參數(shù)膨脹,計算量龐大洽蛀,性能較差

  • 極易陷入局部最優(yōu)解

  • 很容易走向過擬合

卷積神經(jīng)網(wǎng)絡(luò)

卷積神經(jīng)網(wǎng)絡(luò)(Convolutional Neural Network摹迷,CNN)中,卷積層的神經(jīng)元只與前一層的部分神經(jīng)元節(jié)點相連郊供,即它的神經(jīng)元間的連接是非全連接的峡碉,且同一層中某些神經(jīng)元之間的連接的權(quán)重 w 和偏移量 b 是共享的,這樣大大地減少了需要訓(xùn)練的參數(shù)數(shù)量驮审。

卷積神經(jīng)網(wǎng)絡(luò)CNN的結(jié)構(gòu)一般包含這幾個層:

  • 輸入層:用于數(shù)據(jù)的輸入
  • 卷積層:使用卷積核進行特征提取和特征映射
  • 激活層:由于卷積也是一種線性運算鲫寄,因此需要增加非線性映射,使模型具備擬合非線性函數(shù)的能力
  • 池化層:進行采樣疯淫,提取關(guān)鍵特征地来,對特征圖稀疏處理玻粪,減少數(shù)據(jù)運算量
  • 全連接層:在神經(jīng)網(wǎng)絡(luò)尾部進行重新擬合莫绣,減少特征信息的損失
  • 輸出層:用于輸出結(jié)果

在不同場景中,還可以使用一些其他的功能層:

  • 歸一化層(Batch Normalization):對特征進行歸一化
  • 切分層:對某些數(shù)據(jù)(比如圖片)進行分區(qū)域的單獨學(xué)習(xí)
  • 融合層:對獨立進行特征學(xué)習(xí)的分支進行融合

輸入層和輸出層

對于圖像識別的問題來講霹肝,通常卷積神經(jīng)網(wǎng)絡(luò)的數(shù)據(jù)輸入格式與全連接神經(jīng)網(wǎng)絡(luò)的輸入層不一樣适掰,我們希望保留原始圖片的結(jié)構(gòu)颂碧,假設(shè)圖片的尺寸是28×28,每個像素有3個顏色通道(RGB)类浪,那么圖片的數(shù)據(jù)結(jié)構(gòu)如下圖载城。

圖像的像素

如果希望每次迭代輸入100張圖片,那么輸入層的張量結(jié)構(gòu)就是(100, 28, 28, 3)费就。

對應(yīng)于輸入層的數(shù)據(jù)诉瓦,輸出層就是輸入圖片所屬的分類標(biāo)簽,輸入了100張圖片力细,那么輸出層就是這100張圖片所屬的分類標(biāo)簽睬澡。

卷積層

卷積神經(jīng)網(wǎng)絡(luò)的理論中有個概念叫感受視野(local receptive fields),比如我們定義一個5×5的區(qū)域眠蚂,隱藏層的神經(jīng)元與輸入層的5×5個神經(jīng)元相連煞聪,這個5×5的區(qū)域就稱之為感受視野,如下圖所示:

感受視野(local receptive fields)

換一個角度理解逝慧,隱藏層中的神經(jīng)元具有一個固定大小的感受視野去感受上一層的部分特征昔脯。從這個角度看來,在全連接神經(jīng)網(wǎng)絡(luò)中笛臣,隱藏層中的神經(jīng)元的感受視野足夠大以至于可以看到上一層的所有特征云稚,所以它不需要卷積。

而在卷積神經(jīng)網(wǎng)絡(luò)中沈堡,隱藏層中的神經(jīng)元的感受視野比較小静陈,只能看到上一層的部分特征,可以通過平移感受視野來得到上一層的其他特征诞丽,從而得到同一層的其他神經(jīng)元鲸拥,如下圖:

平移感受視野

可以看出,卷積層的神經(jīng)元是只與前一層的部分神經(jīng)元節(jié)點相連率拒,每一條相連的線對應(yīng)一個權(quán)重w崩泡。

一個感受視野帶有一個卷積核,我們將感受視野中的權(quán)重 w 矩陣稱為卷積核(convolutional kernel)猬膨,將感受視野對輸入的掃描間隔稱為步長(stride)角撞,當(dāng)步長比較大時(stride>1),為了掃描到邊緣的一些特征勃痴,感受視野可能會“出界”谒所,這時需要對邊界擴充(pad),邊界擴充可以設(shè)為 0 或其他值沛申。

我們可以定義感受視野的大小劣领,也就是卷積核的大小,卷積核的權(quán)重矩陣的值便是卷積神經(jīng)網(wǎng)絡(luò)的參數(shù)铁材,卷積核可附帶一個偏移項 b 尖淘,它們的初值可以隨機生成或填充為 0奕锌,將會通過訓(xùn)練進行調(diào)整。

那么村生,感受視野掃描時計算出下一層神經(jīng)元的值的公式如下:

對下一層的所有神經(jīng)元來說惊暴,它們從不同的位置去探測了上一層神經(jīng)元的特征。

我們將通過一個帶有卷積核的感受視野掃描生成的下一層神經(jīng)元矩陣稱為一個特征映射圖(feature map)趁桃,同一個特征映射圖上的神經(jīng)元使用的卷積核是相同的辽话,因此這些神經(jīng)元共享卷積核中的權(quán)值和偏移量。

正是有了感受視野和共享參數(shù)卫病,所以我們需要訓(xùn)練的參數(shù)大大減少了油啤,可以節(jié)省很大的計算量。

在圖像識別這個場景中蟀苛,卷積層的工作原理益咬,就是定義一個卷積核矩陣(常用正態(tài)分布矩陣),以一定的步長屹逛,用卷積核矩陣的長和寬對圖像進行采樣础废,把圖像切分成很多個區(qū)域(感受視野,邊緣通常填充為0)罕模,使用卷積核矩陣與這些區(qū)域的值做內(nèi)積運算(兩兩相乘再求和)评腺,得到這些區(qū)域的特征映射圖,這就是所謂的卷積運算淑掌,卷積神經(jīng)網(wǎng)絡(luò)的名字也源于此蒿讥。

如果覺得這段描述太抽象,形象的圖解如下:

卷積運算

定義卷積行為的有幾個參數(shù)抛腕,下面這個圖就是一個步長為2芋绸,卷積核是3x3,深度是2(有兩個卷積核)的卷積層担敌。

卷積運算的過程

所以對卷積層的訓(xùn)練摔敛,其實就是在訓(xùn)練卷積核的參數(shù)(包括權(quán)重和偏移量),從另一個角度理解全封,就是找到合適的方式马昙,把圖像進行重新采樣,并提取出特征刹悴,相當(dāng)于以更有利于計算的方式重新刻畫了這個圖像行楞。

激活層

激活層主要對卷積層的輸出進行一個非線性映射,因為從卷積層的卷積計算公式可以看出卷積運算還是一種線性計算土匀,現(xiàn)實中線性計算所能解決的問題非常有限子房,因為并非所有問題都是線性可分的,只有對線性計算的結(jié)果進行非線性映射,才有可能擬合各種各樣的函數(shù)证杭,所以激活層在神經(jīng)網(wǎng)絡(luò)中扮演著非常重要的角色田度。

激活函數(shù)通常需要具備如下的性質(zhì):

  • 非線性: 當(dāng)激活函數(shù)是非線性的時候,一個兩層的神經(jīng)網(wǎng)絡(luò)就可以逼近基本上所有的函數(shù)了

  • 可微性: 當(dāng)優(yōu)化方法是基于梯度的時候解愤,這個性質(zhì)是必須的每币,反向傳播的過程需要依靠這個性質(zhì)來更新神經(jīng)元的參數(shù)

  • 單調(diào)性: 當(dāng)激活函數(shù)是單調(diào)時,單層神經(jīng)網(wǎng)絡(luò)的誤差函數(shù)是凸函數(shù)琢歇,好優(yōu)化

傳統(tǒng)神經(jīng)網(wǎng)絡(luò)中常用Sigmoid系(Logistic-Sigmoid、Tanh-Sigmoid)的激活函數(shù)梦鉴,這是個很經(jīng)典的激活函數(shù)李茫。從數(shù)學(xué)上來看,非線性的Sigmoid函數(shù)對中央?yún)^(qū)的信號增益較大肥橙,對兩側(cè)區(qū)的信號增益小魄宏,在信號的特征空間映射上,可以很好的將線性結(jié)果映射為非線性結(jié)果(通俗地講就是將直線扭曲為曲線)存筏。從神經(jīng)科學(xué)上來看宠互,中央?yún)^(qū)類似神經(jīng)元的興奮態(tài),兩側(cè)區(qū)類似神經(jīng)元的抑制態(tài)椭坚,因而在神經(jīng)網(wǎng)絡(luò)學(xué)習(xí)時予跌,可以將重點特征推向中央?yún)^(qū)(通過映射可以被激活放大),而非重點特征推向兩側(cè)區(qū)(通過映射將被抑制)善茎。

激活函數(shù)

Relu函數(shù)是現(xiàn)在深度學(xué)習(xí)中使用比較廣泛的激活函數(shù)券册,相比于Sigmoid系激活函數(shù),其優(yōu)點在于計算簡單垂涯,導(dǎo)數(shù)簡單烁焙,收斂快,單側(cè)抑制耕赘,相對寬闊的興奮邊界骄蝇,稀疏激活性(在負(fù)半?yún)^(qū)的導(dǎo)數(shù)為0,節(jié)省計算量)操骡。

Relu函數(shù)

池化層

經(jīng)過卷積層和激活層計算后九火,若感受視野比較小,或步長比較小当娱,得到的特征映射圖還是比較大的吃既,所以還需要通過池化層來對每一個特征映射圖進行降維操作,輸出的深度還是不變的跨细,仍然是特征映射圖的個數(shù)鹦倚。

在這個降維操作中,我們需要定義一個類似感受視野的過濾矩陣來對特征映射圖矩陣進行掃描冀惭,對過濾矩陣中的值進行計算震叙,一般有兩種計算方式:

  • Max pooling:取過濾矩陣中的最大值

  • Average pooling:取過濾矩陣中的平均值

池化層

掃描的過程與卷積層類似掀鹅,每一個特征映射圖都會得到一個降維后的特征矩陣。

池化的目的主要有兩個

  • 進行特征壓縮媒楼,提取主要特征乐尊,提高網(wǎng)絡(luò)的魯棒性,防止過擬合

  • 使用特征圖變小划址,降低網(wǎng)絡(luò)計算復(fù)雜度

下面這個例子以 2X2 為單位進行池化扔嵌,為了使特征更突出,使用了最大化池化(這個過程有點類似于主成分分析)夺颤。

最大化池化

全連接層

全連接層包含一系列分類器(如softmax分類器)痢缎,以上所有層進行計算的結(jié)果,得到了一系列特征矩陣世澜,這些特征矩陣將被重新映射為一維向量独旷,將這個一維向量輸入到全連接層的分類器進行計算,得到這些特征屬于各個分類的概率寥裂,這也就是整個卷積神經(jīng)網(wǎng)絡(luò)的輸出層嵌洼。

準(zhǔn)備數(shù)據(jù)

我們的目標(biāo)是從輸入的圖片中,自動識別包含火焰和包含高塔的圖片封恰。所以我們通過各種途徑搜集了一批包含火焰和包含高塔的圖片麻养,并人工給它們做好了分類。

類別 標(biāo)簽 用途 數(shù)量 目錄
火焰 00001 訓(xùn)練 900 image/train/00001
高塔 00002 訓(xùn)練 583 image/train/00002

先對樣本數(shù)據(jù)來一個預(yù)覽诺舔,每個分類加載若干張圖片回溺,看看數(shù)據(jù)是什么樣的。

import numpy as np
import pandas as pd
import os

work_dir = 'D:/ml/cr-fire-warning'
assert os.path.exists(work_dir), '工作目錄不存在'

data_dir = os.path.join(work_dir, 'data')
assert os.path.exists(data_dir), '樣本目錄不存在'

train_dir = os.path.join(data_dir, 'train')
assert os.path.exists(train_dir), '訓(xùn)練樣本目錄不存在'

test_dir = os.path.join(data_dir, 'test')
assert os.path.exists(test_dir), '測試樣本目錄不存在'

model_dir = os.path.join(work_dir, 'model')
if not os.path.exists(model_dir):
    os.mkdir(model_dir)

# Keras模型
model_name_keras = 'recognize_model_keras.h5'
# TensorFlow模型
model_name_tf = 'recognize_model_tf.pb'

os.chdir(work_dir)
import matplotlib.pyplot as plt
import matplotlib.pylab as pylab

# 設(shè)置畫圖參數(shù)
params = {
        'axes.titlesize': '18',
        'axes.labelsize': '13',
        'xtick.labelsize': '13',
        'ytick.labelsize': '13',
        'lines.linewidth': '2',
        'legend.fontsize': '13',
        'figure.figsize': '6, 5',
#         'figure.facecolor': 'white',
        'figure.facecolor': 'snow',
        # 正常顯示中文
        'font.sans-serif': 'SimHei',
        # 正常顯示負(fù)號
        'axes.unicode_minus': False
    }
pylab.rcParams.update(params)
import skimage.data
import skimage.transform
from PIL import Image

# 加載圖片數(shù)據(jù)
def load_data(label_image_dir, limit_in_class = None, transform = False, image_width = 32, image_height = 32):
    assert os.path.exists(label_image_dir), '樣本目錄不存在'

    labels = []
    images = []
    dirs = [os.path.join(label_image_dir, dir)
        for dir in os.listdir(label_image_dir) if dir.startswith("0")]
    
    for dir in dirs:
        filenames = [os.path.join(dir, f)
                           for f in os.listdir(dir)
                               if f.lower().endswith(".jpg") or f.lower().endswith(".jpeg")
                          ]
        for i in range(0, len(filenames)):
            if (limit_in_class != None and i >= limit_in_class):
                break
            
            filename = filenames[i]
            
            file_type = Image.open(filename).format
            if (file_type != 'JPEG'):
                print('無效圖片格式:' + filename, '文件類型:' + file_type)
                continue
                
            img = skimage.data.imread(filename)
            # 統(tǒng)一成 32*32 的圖像
            if (transform):
                img = skimage.transform.resize(img, (image_width, image_height), mode='constant')
            
            if not hasattr(img[0][0], "__len__"):
                print('無效圖片:' + filename)
                continue
                
            images.append(img)
            labels.append(int(os.path.basename(dir)[:5]))
            
    return images, labels
# 加載圖片
images, labels = load_data(train_dir, limit_in_class = 6, transform = False)
labels_preview = np.array(labels)
images_preview = np.array(images)

# 顯示圖片
plt.figure(figsize=(12, 200))
for i in range(0, len(labels_preview)):
    plt.subplot(50, 3, i + 1)
    plt.title("標(biāo)簽 {0}".format(labels_preview[i]))
    plt.axis('off')
    plt.imshow(images_preview[i])

plt.show()
樣本數(shù)據(jù)

原始圖片尺寸混萝、格式遗遵、分辨率都不一樣,在輸入到神經(jīng)網(wǎng)絡(luò)之前逸嘀,我們還需要統(tǒng)一做一個轉(zhuǎn)換车要。

構(gòu)建卷積神經(jīng)網(wǎng)絡(luò)

我們來構(gòu)建一個兩個卷積層,一個全連接層的神經(jīng)網(wǎng)絡(luò)崭倘。

(輸入層)

-->(卷積層1) --> (激活層1) --> (池化層1) -->

-->(卷積層2) --> (激活層2) --> (池化層2) -->

-->(全連接層)

--> (輸出層)

  • 輸入層:我們希望保留原始圖片的結(jié)構(gòu)翼岁,先把樣本圖片統(tǒng)一轉(zhuǎn)換成32×32尺寸,每個像素有3個顏色通道司光,我們希望每次迭代輸入100張圖片琅坡,那么輸入層的張量結(jié)構(gòu)就是(100, 32, 32, 3)
  • 卷積層1:使用一個5×5的正態(tài)分布的卷積核,對輸入層數(shù)據(jù)進行卷積運算
  • 激活層1:使用ReLu激活函數(shù)
  • 池化層1:使用一個2×2的過濾矩陣残家,對上一層的特征映射圖矩陣進行降維處理榆俺,為了突出圖像特征,池化層使用max pool
  • 卷積層2:使用一個5×5的正態(tài)分布的卷積核,對上一層池化層的結(jié)果進行卷積運算
  • 激活層2:使用ReLu激活函數(shù)
  • 池化層2:使用一個2×2的過濾矩陣茴晋,對上一層的特征映射圖矩陣進行降維處理陪捷,為了突出圖像特征,池化層使用max pool
  • 全連接層:把上一層的結(jié)果重新映射成一維特征向量诺擅,再使用一個dropout運行把特征向量映射到一個1024長度的向量中市袖,并且把概率小于0.4的丟掉
  • 輸出層:把上一層計算的結(jié)果,通過softmax分類器進行分類烁涌,得到屬于各個分類的概率苍碟,取得概率最大的分類,判定屬于該分類

使用均方根優(yōu)化器RMSprop(root mean square prop)撮执,主要目的是為了減緩參數(shù)下降時的擺動驰怎,并允許使用一個更大的學(xué)習(xí)率α,從而加快算法的迭代速率二打。

RMSprop(root mean square prop)是AdaGrad算法的改進,經(jīng)驗上掂榔,RMSProp被證明是有效且實用的深度學(xué)習(xí)網(wǎng)絡(luò)優(yōu)化算法继效。
在每輪迭代中,使用如下公式來更新網(wǎng)絡(luò)參數(shù)装获。



w的在橫軸上變化率很小瑞信,所以dw的值很小,所以Sdw也小穴豫,而b在縱軸上波動很大凡简,所以斜率在b方向上特別大。在這些微分中精肃,db較大秤涩,dw較小。這樣W減去一個較小的數(shù)司抱,總體來說筐眷,W的變化很大。而b的除數(shù)是一個較大的數(shù)习柠,這樣b的更新就會被減緩匀谣,縱向的變化相對平緩。
總的來說资溃,不論w波動幅度大還是b波動幅度大武翎,這個公式總是能夠把幅度大的調(diào)小,幅度小的調(diào)大溶锭,從而達(dá)到減緩參數(shù)擺動的效果宝恶,防止參數(shù)調(diào)整的過程中總是在目標(biāo)點附近擺動。
實際使用中為了防止除0,在分母中加上常數(shù)?卑惜,使用如下公式:

使用了ReduceLROnPlateau來動態(tài)更新學(xué)習(xí)率α膏执,在迭代的過程中,監(jiān)測準(zhǔn)確率val_acc的值露久,如果若干輪迭代之后更米,值仍沒有變化,就按照一定的幅度動態(tài)調(diào)整學(xué)習(xí)率毫痕,以加快迭代速率征峦。

另外在訓(xùn)練的過程中,我們還使用ImageDataGenerator來對輸入的圖片隨機做縮放消请、旋轉(zhuǎn)栏笆、漂移的操作,以增加圖像的多樣化臊泰,提升模型的泛化能力蛉加,也可以有效的避免模型的過擬合。

import numpy as np
from keras.models import Sequential
from keras.layers import Conv2D, MaxPool2D, Dense, Dropout, Activation, Flatten
from keras.optimizers import RMSprop
from keras.preprocessing.image import ImageDataGenerator
from keras.callbacks import ReduceLROnPlateau
from keras.utils import np_utils

# 定義變量
target_classes = 3 # 生成的目標(biāo)分類

image_width = 32 # 圖像寬度
image_height = 32 # 圖像高度
num_channels = 3 # 3 個顏色通道

conv1_filters = 32 # 第一層訓(xùn)練 32 個特征映射圖
conv1_kernel_size = (5, 5) # 第一層卷積核大小
conv2_kernel_size = (5, 5) # 第二層卷積核大小
conv2_filters = 64 # 第二層訓(xùn)練 64 個特征映射圖
max_pool_size1 = 2 # 第一個 max pool 的大小
max_pool_size2 = 2 # 第二個 max pool 的大小
flatten_size = 256 # 全連接層神經(jīng)元數(shù)量
dropout_rate = 0.25 # 隨機失活比率

batch_size = 128 #每批輸入神經(jīng)網(wǎng)絡(luò)的數(shù)據(jù)量
epochs = 100 #迭代次數(shù)

# 設(shè)置為學(xué)習(xí)模式
from keras import backend as K
K.set_learning_phase(0)

input_shape = [image_width, image_height, num_channels]

# np.random.seed(1337)

#構(gòu)建模型
model = Sequential()
# 第一個卷積層缸逃,32個卷積核针饥,大小5x5,卷積模式SAME,激活函數(shù)relu,輸入張量的大小
model.add(Conv2D(input_shape=input_shape, filters= conv1_filters, kernel_size=(conv1_kernel_size[0], conv1_kernel_size[1]),
                 padding='Same', activation='relu'))
# 池化層
model.add(MaxPool2D(pool_size=(max_pool_size1, max_pool_size1)))
# 隨機失活需频,丟棄一部分網(wǎng)絡(luò)連接丁眼,防止過擬合
model.add(Dropout(dropout_rate))

model.add(Conv2D(filters= conv2_filters, kernel_size=(conv2_kernel_size[0], conv2_kernel_size[1]), padding='Same', activation='relu'))
model.add(MaxPool2D(pool_size=(max_pool_size2, max_pool_size2), strides=(2,2)))
model.add(Dropout(dropout_rate))

# 全連接層,展開操作
model.add(Flatten())
# 添加全連接層和激活函數(shù)
model.add(Dense(flatten_size, activation='relu'))
model.add(Dropout(dropout_rate))

# 輸出層
model.add(Dense(target_classes, activation='softmax'))

# #編譯模型
# model.compile(loss='categorical_crossentropy',
#               optimizer='adadelta',
#               metrics=['accuracy'])

# 使用均方根優(yōu)化器RMSprop (root mean square prop)
# lr :學(xué)習(xí)效率, decay :lr的衰減值
optimizer = RMSprop(lr = 0.001, decay=0.0)

# 編譯模型
# loss:損失函數(shù)昭殉,metrics:對應(yīng)性能評估函數(shù)
model.compile(optimizer=optimizer, loss = 'categorical_crossentropy', metrics=['accuracy'])

# keras的callback類提供了可以跟蹤目標(biāo)值苞七,和動態(tài)調(diào)整學(xué)習(xí)效率
# moitor : 要監(jiān)測的量,這里是驗證準(zhǔn)確率
# patience: 當(dāng)經(jīng)過3輪的迭代挪丢,監(jiān)測的目標(biāo)量蹂风,仍沒有變化,就會調(diào)整學(xué)習(xí)效率
# verbose : 信息展示模式乾蓬,攘蛘!0或1
# factor : 每次減少學(xué)習(xí)率的因子,學(xué)習(xí)率將以lr = lr*factor的形式被減少
# mode:‘a(chǎn)uto’巢块,‘min’礁阁,‘max’之一,在min模式下族奢,如果檢測值觸發(fā)學(xué)習(xí)率減少姥闭。在max模式下,當(dāng)檢測值不再上升則觸發(fā)學(xué)習(xí)率減少越走。
# epsilon:閾值棚品,用來確定是否進入檢測值的“平原區(qū)”
# cooldown:學(xué)習(xí)率減少后靠欢,會經(jīng)過cooldown個epoch才重新進行正常操作
# min_lr:學(xué)習(xí)率的下限
learning_rate_reduction = ReduceLROnPlateau(monitor = 'val_acc', patience = 3,
                                            verbose = 1, factor=0.5, min_lr = 0.00001)

# 數(shù)據(jù)增強處理,提升模型的泛化能力铜跑,也可以有效的避免模型的過擬合
# rotation_range : 旋轉(zhuǎn)的角度
# zoom_range : 隨機縮放圖像
# width_shift_range : 水平移動占圖像寬度的比例
# height_shift_range 
# horizontal_filp : 水平反轉(zhuǎn)
# vertical_filp : 縱軸方向上反轉(zhuǎn)
data_augment = ImageDataGenerator(rotation_range= 10, zoom_range= 0.1,
                                  width_shift_range = 0.1, height_shift_range = 0.1,
                                  horizontal_flip = False, vertical_flip = False)
Using TensorFlow backend.

訓(xùn)練神經(jīng)網(wǎng)絡(luò)

神經(jīng)網(wǎng)絡(luò)搭建好了门怪,我們把樣本數(shù)據(jù)輸入到神經(jīng)網(wǎng)絡(luò)中進行訓(xùn)練。

訓(xùn)練的步驟如下:

  1. 加載所有分類的樣本圖片锅纺,統(tǒng)一轉(zhuǎn)換為32×32的尺寸掷空,每張圖片有RGB一共3個顏色通道
  2. 將數(shù)據(jù)切分為訓(xùn)練集和測試集,并對每一個像素值進行歸一化處理
  3. 將訓(xùn)練集數(shù)據(jù)輸入到神經(jīng)網(wǎng)絡(luò)中進行迭代訓(xùn)練
  4. 訓(xùn)練完成后囤锉,畫出loss和accuracy曲線坦弟,觀察訓(xùn)練是否收斂,loss和accuracy是否滿足要求
  5. 使用模型對測試數(shù)據(jù)進行預(yù)測官地,根據(jù)預(yù)測結(jié)果計算混淆矩陣酿傍,評估模型效果
from sklearn.model_selection import train_test_split

images, labels = load_data(train_dir, limit_in_class = None, transform = True, image_width = image_width, image_height = image_height)
images = np.array(images)
labels = np.array(labels)


# 從訓(xùn)練數(shù)據(jù)中分出十分之一的數(shù)據(jù)作為驗證數(shù)據(jù)
X_train , X_test , y_train, y_test = train_test_split(images, labels, test_size=0.1, random_state=3)

X_train = X_train.astype('float32')
X_test = X_test.astype('float32')
X_train /= 255
X_test /= 255
print('訓(xùn)練集樣本數(shù)', X_train.shape[0])
print('測試集樣本數(shù)', X_test.shape[0])

# 轉(zhuǎn)換為one_hot類型
Y_train = np_utils.to_categorical(y_train, target_classes)
Y_test = np_utils.to_categorical(y_test, target_classes)
無效圖片格式:D:/ml/cr-fire-warning\data\train\00001\2105469964_9022d70cab.jpg 文件類型:PNG
無效圖片:D:/ml/cr-fire-warning\data\train\00002\tower (12).jpg
訓(xùn)練集樣本數(shù) 1331
測試集樣本數(shù) 148
#訓(xùn)練模型
history = model.fit_generator(data_augment.flow(X_train, Y_train, batch_size=batch_size), epochs=epochs,
          callbacks=[learning_rate_reduction], verbose=1, validation_data=(X_test, Y_test))

#評估模型
score = model.evaluate(X_test, Y_test, verbose=1)

print('Test score:', score[0])
print('Test accuracy:', score[1])
Epoch 1/100
11/11 [==============================] - 8s 724ms/step - loss: 0.7897 - acc: 0.5528 - val_loss: 0.7190 - val_acc: 0.5608
Epoch 2/100
11/11 [==============================] - 7s 667ms/step - loss: 0.6974 - acc: 0.5781 - val_loss: 0.7217 - val_acc: 0.5608
Epoch 3/100
11/11 [==============================] - 7s 671ms/step - loss: 0.6339 - acc: 0.6398 - val_loss: 0.4619 - val_acc: 0.8514

Epoch 99/100
11/11 [==============================] - 7s 669ms/step - loss: 0.1609 - acc: 0.9417 - val_loss: 0.1391 - val_acc: 0.9730
Epoch 100/100
11/11 [==============================] - 7s 669ms/step - loss: 0.1494 - acc: 0.9464 - val_loss: 0.1383 - val_acc: 0.9662
148/148 [==============================] - 0s 3ms/step
Test score: 0.138349657627
Test accuracy: 0.966216216216

把損失函數(shù)的值畫出來,看看訓(xùn)練過程是否收斂驱入。

def p():
    fig,ax = plt.subplots(2,1,figsize=(10,10))
    ax[0].plot(history.history['loss'], color='r', label='Training Loss')
    ax[0].plot(history.history['val_loss'], color='g', label='Validation Loss')
    ax[0].legend(loc='best',shadow=True)
    ax[0].grid(True)


    ax[1].plot(history.history['acc'], color='r', label='Training Accuracy')
    ax[1].plot(history.history['val_acc'], color='g', label='Validation Accuracy')
    ax[1].legend(loc='best',shadow=True)
    ax[1].grid(True)

    plt.show()
    
plot_learning_curves()
import itertools
from sklearn.metrics import confusion_matrix

# 混淆矩陣
def plot_confusion_matrix(cm, classes, normalize=False, title='混淆矩陣',cmap=plt.cm.Blues):
    plt.imshow(cm, interpolation='nearest', cmap=cmap)
    plt.title(title)
    plt.colorbar()
    tick_marks = np.arange(len(classes))
    plt.xticks(tick_marks, classes, rotation=45)
    plt.yticks(tick_marks, classes)
    if normalize:
        cm = cm.astype('float')/cm.sum(axis=1)[:, np.newaxis]
    thresh = cm.max()/2.0
    for i,j in itertools.product(range(cm.shape[0]), range(cm.shape[1])):
        plt.text(j,i,cm[i,j], horizontalalignment='center',color='white' if cm[i,j] > thresh else 'black')
    plt.tight_layout()
    plt.ylabel('真值')
    plt.xlabel('預(yù)測值')
    
    plt.show()

    
pred_y = model.predict(X_test)
pred_label = np.argmax(pred_y, axis=1)

cm = confusion_matrix(y_test, pred_label)

plot_confusion_matrix(cm, classes = range(4))
混淆矩陣

保存模型

訓(xùn)練完成后赤炒,我們得到了神經(jīng)網(wǎng)絡(luò)中所有神經(jīng)元之間連接的權(quán)重和偏移量參數(shù),這就是我們的訓(xùn)練成果亏较,我們把它作為模型保存起來莺褒,這樣下次要進行預(yù)測時直接加載模型即可,不用每次都重新訓(xùn)練模型宴杀。

我們使用的是keras計算框架,調(diào)用模型自身的保存接口拾因,保存模型旺罢。keras自身保存的模型文件格式是hdf5。

為方便模型的傳遞交換和多語言調(diào)用绢记,我們把神經(jīng)網(wǎng)絡(luò)的結(jié)構(gòu)和所有定義的變量扁达、所有訓(xùn)練得到的參數(shù)都固化下來,以protocal buffer(pb)格式蠢熄,保存到TensorFlow可以解析的單獨模型文件中跪解。

# 使用keras自身的接口保存模型
def save_model_keras(model, model_dir, model_name):
    model_path = os.path.join(model_dir, model_name)
    model.save(model_path)
    
    return model_path

# 保存keras模型
save_model_keras(model, model_dir, model_name_keras)
'D:/ml/cr-fire-warning\\model\\recognize_model_keras.h5'
import tensorflow as tf

def freeze_session(session, keep_var_names=None, output_names=None, clear_devices=True):
    """
    Freezes the state of a session into a prunned computation graph.

    Creates a new computation graph where variable nodes are replaced by
    constants taking their current value in the session. The new graph will be
    prunned so subgraphs that are not neccesary to compute the requested
    outputs are removed.
    @param session The TensorFlow session to be frozen.
    @param keep_var_names A list of variable names that should not be frozen,
                          or None to freeze all the variables in the graph.
    @param output_names Names of the relevant graph outputs.
    @param clear_devices Remove the device directives from the graph for better portability.
    @return The frozen graph definition.
    """
    from tensorflow.python.framework.graph_util import convert_variables_to_constants
    graph = session.graph
    with graph.as_default():
        freeze_var_names = list(set(v.op.name for v in tf.global_variables()).difference(keep_var_names or []))
        output_names = output_names or []
        output_names += [v.op.name for v in tf.global_variables()]
        input_graph_def = graph.as_graph_def()
        if clear_devices:
            for node in input_graph_def.node:
                node.device = ""
        frozen_graph = convert_variables_to_constants(session, session.graph_def, output_names)
#         frozen_graph = convert_variables_to_constants(session, input_graph_def, output_names, freeze_var_names)
        return frozen_graph
# 將模型保存為TensorFlow可以解析的pb格式
def save_model_tf(model_dir, model_name):
    frozen_graph = freeze_session(K.get_session(), output_names=[model.output.op.name])
#     tf.train.write_graph(frozen_graph, model_dir, model_name, as_text=False)
    from tensorflow.python.framework import graph_io
    graph_io.write_graph(frozen_graph, model_dir, model_name, as_text=False)
    return os.path.join(model_dir, model_name)
    
save_model_tf(model_dir, model_name_tf)
INFO:tensorflow:Froze 20 variables.
Converted 20 variables to const ops.
'D:/ml/cr-fire-warning\\model\\recognize_model_tf.pb'

使用模型進行預(yù)測

訓(xùn)練已經(jīng)完成,我們得到一個模型签孔,驗證了模型的準(zhǔn)確率叉讥,并且作為文件保存起來。接下來我們重新加載模型饥追,使用模型對全新的圖片進行預(yù)測图仓,看看模型的表現(xiàn)怎么樣。

import skimage.data
import skimage.transform
from PIL import Image

# 加載圖片數(shù)據(jù)
def load_predict_data(image_dir, limit_in_class = None, transform = False, image_width = 32, image_height = 32):
    assert os.path.exists(image_dir), '樣本目錄不存在'

    images = []
    dirs = [image_dir]
    
    for dir in dirs:
        filenames = [os.path.join(dir, f)
                           for f in os.listdir(dir)
                               if f.lower().endswith(".jpg") or f.lower().endswith(".jpeg")
                          ]
        for i in range(0, len(filenames)):
            if (limit_in_class != None and i >= limit_in_class):
                break
            
            filename = filenames[i]
            
            file_type = Image.open(filename).format
            if (file_type != 'JPEG'):
                print('無效圖片格式:' + filename, '文件類型:' + file_type)
                continue
                
            img = skimage.data.imread(filename)
            # 統(tǒng)一成 32*32 的圖像
            if (transform):
                img = skimage.transform.resize(img, (image_width, image_height), mode='constant')
            
            if not hasattr(img[0][0], "__len__"):
                print('無效圖片:' + filename)
                continue
                
            images.append(img)
            
    return images
predict_dir = os.path.join(data_dir, 'predict')
assert os.path.exists(predict_dir), '圖片目錄不存在'

predict_images = load_predict_data(predict_dir, transform = True, image_width = image_width, image_height = image_height)
predict_images_a = np.array(predict_images)
image_predict = predict_images_a.astype('float32')
image_predict /= 255

print("images shape {0}".format(image_predict.shape))
images shape (13, 32, 32, 3)
model_path_keras = os.path.join(model_dir, model_name_keras)
assert os.path.exists(model_path_keras), '模型文件不存在'

from keras.models import load_model
model_keras = load_model(model_path_keras)

def recognize_by_keras_model(model_keras, predict_data):
    predict_y = model_keras.predict(predict_data)
    predict_labels = np.argmax(predict_y, axis=1)
    
    return predict_labels
predicted_labels = recognize_by_keras_model(model_keras, image_predict)
print(predicted_labels)
[1 1 1 2 2 2 2 2 1 2 2 1 1]
# 定義神經(jīng)網(wǎng)絡(luò)的輸入層
# 圖片的大小是32*32*3的但绕,對應(yīng)圖片的是長寬和三色通道救崔,第一個參數(shù)表示一批數(shù)據(jù)的大小惶看。
image_input_shape = [None, image_width, image_height, num_channels]
images_ph = tf.placeholder(tf.float32, image_input_shape)

input_name = model.input.name
output_name = model.output.name

def load_tf_model(model_path):
    with tf.Session() as sess:
        with open(model_path, 'rb') as f:
            graph_def = tf.GraphDef()
            graph_def.ParseFromString(f.read())
    output = tf.import_graph_def(graph_def, input_map={input_name:images_ph}, return_elements=[output_name], name='')

    return (sess, output)
model_path_tf = os.path.join(model_dir, model_name_tf)
assert os.path.exists(model_path_tf), '模型文件不存在'
print(model_path_tf)

(sess, output) = load_tf_model(model_path_tf)
D:/ml/cr-fire-warning\model\recognize_model_tf.pb
def recognize_by_tf_model(session, output, predict_data):
    feed_dict = {images_ph: predict_data}
    predict_y = session.run(output, feed_dict=feed_dict)[0]
    print(predict_y)
    predict_labels = np.argmax(predict_y, axis=1)

    return predict_labels

predicted_labels = recognize_by_tf_model(sess, output, image_predict)
print(predicted_labels)
[[  4.58848517e-05   9.88525569e-01   1.14285639e-02]
 [  1.96375791e-02   6.36299133e-01   3.44063252e-01]
 [  2.28518602e-05   9.92211938e-01   7.76528846e-03]
 [  1.33850204e-03   3.42030311e-03   9.95241165e-01]
 [  6.35846576e-04   6.99755270e-03   9.92366612e-01]
 [  6.32785785e-04   3.95553000e-03   9.95411694e-01]
 [  6.32785785e-04   3.95553000e-03   9.95411694e-01]
 [  1.27463706e-03   6.01934362e-03   9.92706001e-01]
 [  1.28740809e-04   9.79152739e-01   2.07185037e-02]
 [  3.23435441e-02   2.13835035e-02   9.46272910e-01]
 [  1.83394831e-02   2.83331811e-01   6.98328733e-01]
 [  7.92038257e-09   9.99818265e-01   1.81791780e-04]
 [  2.32125126e-06   9.95894074e-01   4.10356373e-03]]
[1 1 1 2 2 2 2 2 1 2 2 1 1]
# 可視化預(yù)測結(jié)果
def plot_predicted():
    fig = plt.figure(figsize=(30, 10))
    for i in range(len(predict_images_a)):
        prediction = predicted_labels[i]
        plt.subplot(5, 5, 1+i)
        plt.axis('off')
        color = 'blue'
        plt.text(40, 15, "預(yù)測分類 {0}".format(prediction), fontsize=16, color=color)
        plt.imshow(predict_images_a[i])

    plt.show()
    
plot_predicted()
預(yù)測結(jié)果
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市六孵,隨后出現(xiàn)的幾起案子纬黎,更是在濱河造成了極大的恐慌,老刑警劉巖劫窒,帶你破解...
    沈念sama閱讀 206,968評論 6 482
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件本今,死亡現(xiàn)場離奇詭異,居然都是意外死亡烛亦,警方通過查閱死者的電腦和手機诈泼,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,601評論 2 382
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來煤禽,“玉大人铐达,你說我怎么就攤上這事∶使” “怎么了瓮孙?”我有些...
    開封第一講書人閱讀 153,220評論 0 344
  • 文/不壞的土叔 我叫張陵,是天一觀的道長选脊。 經(jīng)常有香客問我杭抠,道長,這世上最難降的妖魔是什么恳啥? 我笑而不...
    開封第一講書人閱讀 55,416評論 1 279
  • 正文 為了忘掉前任偏灿,我火速辦了婚禮,結(jié)果婚禮上钝的,老公的妹妹穿的比我還像新娘翁垂。我一直安慰自己,他們只是感情好硝桩,可當(dāng)我...
    茶點故事閱讀 64,425評論 5 374
  • 文/花漫 我一把揭開白布沿猜。 她就那樣靜靜地躺著,像睡著了一般碗脊。 火紅的嫁衣襯著肌膚如雪啼肩。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,144評論 1 285
  • 那天衙伶,我揣著相機與錄音祈坠,去河邊找鬼。 笑死矢劲,一個胖子當(dāng)著我的面吹牛颁虐,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播卧须,決...
    沈念sama閱讀 38,432評論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼另绩,長吁一口氣:“原來是場噩夢啊……” “哼儒陨!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起笋籽,我...
    開封第一講書人閱讀 37,088評論 0 261
  • 序言:老撾萬榮一對情侶失蹤蹦漠,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后车海,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體笛园,經(jīng)...
    沈念sama閱讀 43,586評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,028評論 2 325
  • 正文 我和宋清朗相戀三年侍芝,在試婚紗的時候發(fā)現(xiàn)自己被綠了研铆。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 38,137評論 1 334
  • 序言:一個原本活蹦亂跳的男人離奇死亡州叠,死狀恐怖棵红,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情咧栗,我是刑警寧澤逆甜,帶...
    沈念sama閱讀 33,783評論 4 324
  • 正文 年R本政府宣布,位于F島的核電站致板,受9級特大地震影響交煞,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜斟或,卻給世界環(huán)境...
    茶點故事閱讀 39,343評論 3 307
  • 文/蒙蒙 一素征、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧萝挤,春花似錦御毅、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,333評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽咽块。三九已至绘面,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間侈沪,已是汗流浹背揭璃。 一陣腳步聲響...
    開封第一講書人閱讀 31,559評論 1 262
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留亭罪,地道東北人瘦馍。 一個月前我還...
    沈念sama閱讀 45,595評論 2 355
  • 正文 我出身青樓,卻偏偏與公主長得像应役,于是被迫代替她去往敵國和親情组。 傳聞我的和親對象是個殘疾皇子燥筷,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 42,901評論 2 345

推薦閱讀更多精彩內(nèi)容