TensorFlow技術介紹及使用


TensorFlow是目前世界上最受歡迎的深度學習框架,主要應用於圖像識別、語言理解、語音理解等領域方面。它具有快速、靈活並適合產品及大規模應用等特點。公司里的AI裝維質檢以及文本分析方面皆可通過TensorFlow實現。希望通過對本文的學習,大家對TensorFlow的有所了解,並可以使用TensorFlow做一些實踐,體驗一下TensorFlow的奇妙之處。

1 什么是TensorFlow?

TensorFlow是Google Brain的第二代機器學習系統,已經開源。TensorFlow受到了AI開發社區的廣泛歡迎,是Github上最受歡迎的深度學習框架之一,也是整個社區上fork最多的項目。目前,TensorFlow已經被下載了超過790萬次。Tensorflow是一個采用數據流圖(data flow graphs),用於數值計算的開源軟件庫,常被應用於各種感知、語言理解、語音識別、圖像識別等多項機器深度學習領域。

1577948980015

TensorFlow使用計算圖表來執行其所有的計算。計算被表示為tf.Graph對象的一個實例,而其中的數據被表示為tf.Tensor對象,並使用tf.Operation對象對這樣的張量對象進行操作。然后再使用tf.Session對象的會話中執行該圖表。

圖中節點(Nodes)一般表示施加的數學操作,或者表示數據輸入(feed in)的起點/輸出(push out)的終點,或者是讀取/寫入持久變量(persistent variable)的終點

圖中線/邊(edges)則表示在節點間相互聯系的多維數據組,即張量(tensor)。

TF工作流程:

1) 建立一個計算圖

2) 初始化變量

3) 創建會話

4) 在會話中運行計算流圖

5) 關閉會話(保持代碼完整性)

例子:用TF擬合逼近線性函數,這是TF實現線性回歸算法的一個例子----幫助理解TF基本工作流程

##用TensorFlow擬合逼近線性函數
# 導入相關包
import tensorflow as tf
import numpy as np

##create data ##
x_data=np.random.rand(100).astype(np.float32) #隨機生成100個浮點數
y_data=x_data*0.1+0.3

##創建TensorFlow變量
Weights=tf.Variable(tf.random_uniform([1],-1.0,1.0))#創建一個變量,初始值是一個-1至1之間的隨機值;[1]表示1維
biases=tf.Variable(tf.zeros([1])) #創建一個一維的初始值為0的變量

# 創建線性模型
y=Weights*x_data+biases

# 最小方差
loss=tf.reduce_mean(tf.square(y-y_data))
optimizer=tf.train.GradientDescentOptimizer(0.5)#0.5為學習效率,此為優化器
train=optimizer.minimize(loss)

# 初始化變量
init=tf.global_variables_initializer()#初始化

# 建立會話
with tf.Session() as sess:
    sess.run(init) # 激活變量初始化
    for step in range(201):#迭代次數:201次
        sess.run(train)
        if step%20==0:
            print(step,sess.run(Weights),sess.run(biases)) #打印迭代次數、Weights、biases

輸出結果

1577949270773

2 TensorFlow基本概念

2.1 TF數據模型-tensor

2.1.1 張量的基本概念

Tensorflow中最基本的單位是常量變量占位符,這些所有的數據都通過張量(Tensor的形式來表示。張量可以被理解為類型化的多維數組。0維張量是一個數字,也稱為標量;1維張量稱為“向量”,即一維數組;2維張量稱為矩陣……第n階張量可以理解為一個n維數組。一個張量中主要保存了三個屬性:名字****name維度****shape類型****type

# eg:
import tensorflow
a=tf.constant([1,2,3])
b=tf.constant([2,3,5])
result=tf.add(a,b,name=”add”)
print(result)

運行結果:tensor(“add:0”,shape=(3,),dtype=int32)

add:0表示result這個張量是計算節點”add”輸出的第一個結果;

shape=(3, )表示張量是一個一維數組,這個數組的長度是3;

dtype=int32 是數據類型,TensorFlow會對參與運算的所有張量進行類型的檢查,當類型不匹配時會報錯。

2.1.2 張量的表示

1TF****常量表示

tf.constant()構造。常量可作為儲存超參數或其他結構信息的變量。如a=tf.constant(2,tf.int32,name=”a”),生成一個值為2,類型為int32,名為a的常量。

# tensor1是一個0維的int32 tensor
tensor1=tf.constant(1234)
# tensor2是一個1維的int32 tensor
tensor2=tf.constant(123,456,789)
# tensor3是一個二維的int32 tensor
tensor3=tf.constant([123,456,789],[222,333,444])

2TF****變量表示

tf.Variable()構造。在設置完成后改變,但變量的數據類型和形狀無法改變。當訓練模型時,變量一般用來存儲和更新參數,建模時變量需要被明確地初始化。TensorFlow提供了一系列操作符來初始化張量,初始值是常量或是隨機值。在初始化時需要指定張量的shape,變量的shape通常是固定的。如,b=tf.Variable([1,3],name=”vector”) 定義了一個名為vector的,shape為[2,],初始值為[1,3]的向量。

weights=tf.Variable(tf.random_normal([2,3],stddev=2),name=”weights”)

這里生成2*3的矩陣,元素的均值為0,標准差為2,初始值為隨機值。

常數生成函數:tf.zeros()tf.ones()能夠創建一個初始值為0或1的張量。

隨機生成函數:tf.random_normal()夠創建一個包含多個隨機值的張量。tf.truncated_normal()能夠創建一個包含從截斷的正態分布中隨機抽取的值的張量。

3TF****占位符表示

tf.placeholder()構造。占位符不需要初始值,僅用於分配必要的內存空間。輸入輸出數據在tf中是用placeholder占位符來定義的。

X = tf.placeholder(tf.float32, shape=[None, 100])

上面聲明了一個張量X,數據類型是float,100列,行數不確定。

占位符在運行時,通過 Session.run 的函數的 feed_dict填入(外部)數據。如下所示

# 導入相關包
import tensorflow as tf
input1=tf.placeholder(tf.float32)#定義一個浮點型的占位符
input2=tf.placeholder(tf.float32)
output=tf.multiply(input1,input2) #相乘操作
# 建立會話
with tf.Session() as sess:
    # 將浮點型數據7和2通過feed_dict分別喂給input1和input2
    print(sess.run(output,feed_dict={input1:[7.],input2:[2.]}))

結果輸出:[14.]

2.2 TF運行模型-session

TensorFlow的運行模型為session(會話),會話擁有和管理TensorFlow程序運行時的所有資源,當計算完成后,需要關閉會話來幫助系統收回資源,否則就可能出現資源泄漏的問題。

tensorflow構建的計算圖必須通過Session會話才能執行,如果只是在計算圖中定義了圖的節點但沒有使用Session會話的話,就不能運行該節點。比如在tensorflow中定義了兩個矩陣a和b,和一個計算a和b乘積的c節點,如果想要得到a和b的乘積(也就是c節點的運算結果)的話,必須要建立Session會話,並調用Session中的run方法運行c節點才行。

TensorFlow使用會話的方式有如下兩種:

# 第一種
sess=tf.Session() #創建一個會話
sess.run(result)  #運行此會話
sess.close()     #運行完畢關閉此會話

# 第二種
#通過python的上下文管理器來使用會話,當上下文退出時會話管理和資源釋放也自動完成
with tf.Session() as sess:
    sess.run(result)  

運行結果:array([3,5,8],dtype=int32)

當使用第一種模式,程序因為異常而退出時,關閉會話的函數就可能不會執行從而導致資源泄漏,因此推薦使用第二種模式。

2.3 TF計算模型-graph

TensorFlow的計算模型為Graph(圖),所有不同的變量以及對這些變量的操作都保存在圖中。一個TensorFlow圖描述了計算的過程,為了進行計算,圖必須在會話里被啟動,會話將圖的op(節點)分發到諸如CPU或GPU之類的設備上,同時提供執行op的方法。這些方法執行后,將產生的tensor返回。TensorFlow中可利tf.summary.FileWriter()將計算圖寫入到文件中,通過tensorboard可視化計算流圖。

import tensorflow as tf
with tf.name_scope('graph') as scope:
    #創建兩個張量(Tensor)
    a=tf.constant(5,name="a")#定義一個節點名稱為a,值為b的常量
    b=tf.constant(10,name="b")
c=tf.add(a,b,name="add") #定義操作
#執行計算
with tf.Session() as sess:
    writer=tf.summary.FileWriter("logs/",sess.graph)#將計算圖寫入到logs/目錄下的一個文件中
    init=tf.global_variables_initializer() #初始化激活
    sess.run(init)

執行上述代碼后,在cmd中輸入tensorboard --logdir=PATH\logs(logs文件夾的路徑),得到一個網址,復制到瀏覽器即可查看tensorboard內容。

生成如下圖:

1577949504977

TensorFlow的Tensor表明了它的數據結構,而Flow則直觀地表現出張量之間通過計算互相轉化的過程。TensorFlow中每一個計算都是圖上的一個節點,節點之間的邊描述了計算之間的依賴關系,如上圖所示,a、b為常量,不依賴任何計算,而add計算則依賴讀取兩個常量的取值。

Tensorflow程序一般可以分為兩個階段。在第一個階段要定義計算圖中所有的計算,第二個階段為執行計算。

在tensorflow程序中,系統會自動維護一個默認的計算圖。可以通過tf.Graph函數生成新的計算圖,需要注意的是:不同計算圖上的張量和運算都不會共享。

Tensorflow中的計算圖不僅僅可以用來隔離張量和計算,他還提供了管理張量和計算的機制。計算圖可以通過函數來指定運行計算的設備。這為使用提供了機制。

import tensorflow as tf
g=tf.Graph
with g.device('/gpu:0'):
    result=a+b

3 TensorFlow訓練神經網絡(CNN)

人工神經網絡是一種模仿生物神經網絡的結構和功能的數學模型或者計算模型,主要用來做分類識別等應用。單個神經元的結構如下圖所示。

1577949595309

輸出是由輸入數據乘以同緯度的權重向量加上一個偏置量所得,即Y=wx+b。神經網絡的基本結構如下。神經網絡的就是計算最佳參數的過程。

1577949611595

3.1 CNN基礎知識

卷積神經網絡CNN是一種前饋神經網絡,它由三部分構成。第一部分是輸入層。第二部分由n個卷積層和池化層(采樣層)的組合組成。第三部分由一個全連接的多層感知機分類器構成。卷積層負責提取特征,采樣層負責特征選擇,全連接層負責分類。卷積神經網絡,就是會自動的對於一張圖片學習出最好的卷積核以及這些卷積核的組合方式,然后來進行判斷。卷積神經網絡以參數少、訓練快、得分高、易遷移的特點全面碾壓之前簡單的神經網絡。

1577949814584

1577949825802

​ 汽車識別

·****卷積層

卷積的主要目的是為了從輸入圖像中提取特征。卷積可以通過從輸入的一小塊數據中學到圖像的特征,並可以保留像素間的空間關系。和傳統的全連接層不同,卷積層中的每一個節點的輸入只是上一層神經網絡的一小塊,這個小塊常用的大小有33或者55。卷積層試圖將神經網絡中的每一小塊進行更加深入地分析從而得到抽象程度更高的特征。一般來說,通過卷積層處理過的節點矩陣會變得更深,如下圖,這個結構被稱為卷積核(Kernel)或過濾器(Filter),卷積核放在神經網絡里,就代表對應的權重(weight)。

#卷積遍歷垂直、水平方向步數為1(中間的兩個1),SAME:邊緣外自動補0,遍歷相乘


tf.nn.conv2d(x, W, strides = [1,1,1,1], padding = 'SAME')

1577949848912

1577949860026

·****池化層

池化層降低了各個特征圖的維度,但可以保持大部分重要的信息。池化方法有下面幾種方式:最大化、平均化、加和等等。對於最大池化(Max Pooling),定義了一個空間領域(比如,2*2的窗口),並從窗口內的修正特征圖中取出最大的元素。除了最大池化,也可以用平均值,或者對窗口內的元素求和。在實際中,最大池化被證明效果更好一些。

池化函數可以逐漸降低輸入表示的空間尺度,使輸入數據(特征維度)變得更小,並且網絡中的參數和計算的數量更加可控的減小,因此可以控制過擬合。

#池化層采用kernel大小為2*2,步數也為2,周圍補0**,取最大值

tf.nn.max_pool(x, ksize=[1,2,2,1], strides=[1,2,2,1], padding='SAME')

1577949875398

3.2 TensorFlow訓練神經網絡步驟

1577949884245

包含神經網絡的圖(如上圖所示)應包含以下步驟:

  1. 輸入數據集:訓練數據集和標簽、測試數據集和標簽(以及驗證數據集和標簽)。
    測試和驗證數據集可以放在tf.constant()中。而訓練數據集被放在tf.placeholder()中,這樣它可以在訓練期間分批輸入(隨機梯度下降)。
  2. 神經網絡模型及其所有的層定義,即定義神經網絡的結構和前向傳播的輸出結果。如卷積層、池化層、全連接層等。這可以是一個簡單的完全連接的神經網絡,僅由一層組成,或者由5、9、16層組成的更復雜的神經網絡。
  3. 權重矩陣偏差矢量以適當的形狀進行定義和初始化。(每層一個權重矩陣和偏差矢量)
  4. 損失值:模型可以輸出分對數矢量(估計的訓練標簽),並通過將分對數與實際標簽進行比較,計算出損失值(具有交叉熵函數的softmax)。損失值表示估計訓練標簽與實際訓練標簽的接近程度,並用於更新權重值。
  5. 優化器:它用於將計算得到的損失值來更新反向傳播算法中的權重和偏差。

最后生成會話並且將預處理后的數據喂入神經網絡,反復運行反向傳播優化算法。

完整實例程序如下:

import tensorflow as tf
from numpy.random import RandomState
batch_size=8   #定義訓練數據batch的大小
# 定義權重變量
w1=tf.Variable(tf.random_normal([2,3],stddev=1,seed=1))
w2=tf.Variable(tf.random_normal([3,1],stddev=1,seed=1))
# 定義輸入數據的占位符
x=tf.placeholder(tf.float32,shape=(None,2),name="x-input")
y_=tf.placeholder(tf.float32,shape=(None,1),name="y-input")
a=tf.matmul(x,w1) #定義操作相乘
y=tf.matmul(a,w2)
#定義損失函數和反向傳播的算法
cross_entropy=-tf.reduce_mean(y_*tf.log(tf.clip_by_value(y,1e-10,1.0)))
train_step=tf.train.AdamOptimizer(0.001).minimize(cross_entropy)#優化器,學習速率是0.001
rdm=RandomState(1)#隨機數生成器
X=rdm.rand(128,2) #隨機生成128*2的數組
Y=[[int(x1+x2<1)] for (x1,x2) in X] #標簽Y值:0/1,128行
with tf.Session() as sess:
    init_op=tf.global_variables_initializer()
    sess.run(init_op)
    
    #輸出目前(未經訓練)的參數取值
    print("w1:",sess.run(w1))
    print("w2:",sess.run(w2))
    print("\n")
    #訓練模型
    steps=5000 #迭代次數5000次
    for i in range(steps):
        start=(i*batch_size)%128
        end=(i*batch_size)%128 +batch_size
        sess.run(train_step,feed_dict={x:X[start:end],y_:Y[start:end]})#喂數據
        if i%1000==0:
            total_cross_entropy=sess.run(cross_entropy,feed_dict={x:X,y_:Y})#交叉熵損失函數
            print("After %d step(s),crossentropy on all data is %g" %(i,total_cross_entropy))
        
    # 輸出訓練后的參數取值,運行結果如
    print("\n")
    print("w1:",sess.run(w1))
    print("w2:",sess.run(w2)) 

訓練前權重矩陣: 1577949960256

訓練后權重矩陣: 1577949965959

3.3 MNIST手寫數字識別實例

舉個常見例子,手寫數字識別---圖片分類問題

MNIST數據集

---包含70000張手寫數字黑白圖片,將其中60000張作為訓練集,10000張作為測試集。每個MNIST數據單元有兩部分組成:一張包含手寫數字的圖片xs(每一張圖片包含28x28像素)和一個對應的標簽ys

1577949897111

把圖片這個數組展開成一個向量,長度是28x28 = 784。如何展開(橫向/縱向…)不重要,只要保持各個圖片采用相同的方式展開就行(注:這里只是簡單介紹TF基本模型,對於圖片的二維結構信息暫不考慮)

在訓練集中,xs是一個形狀為[60000, 784]的張量,第一維用來索引圖片,第二維用來索引每張圖片中的像素點;每個標簽ysi為一個one-hot向量,形如[0,1,0,0,0,0,0,0,0,0]

MNIST的每張圖片代表一個0-9的數字,現在希望得到給定圖片代表每個數字的概率,這是一個典型的softmax回歸案例。簡單介紹softmax回歸模型

對於輸入的xs加權求和,再分別加上一個偏置項,最后再輸入到softmax函數中,轉成概率形式:

1577949929270 1577949910990

代碼:

# 導入相關包
import tensorflow as tf
from tensorflow.examples.tutorials.mnist import input_data
from PIL import Image, ImageFilter
import matplotlib.pyplot as plt

# 讀取圖片數據集
mnist = input_data.read_data_sets('../data/MNIST_data/',one_hot=True)

'''模型構建'''
# 聲明一個占位符,None表示輸入圖片的數量不定,28*28圖片分辨率,即
x = tf.placeholder(tf.float32, [None, 784])
# 類別是0-9數字,總共10個類別,對應輸出分類結果
y_ = tf.placeholder(tf.float32, [None, 10])

def weight_variable(shape):
    # 正態分布,標准差為0.1,默認最大為1,最小為-1,均值為0
    initial = tf.truncated_normal(shape,stddev = 0.1)
    return tf.Variable(initial)

def bias_variable(shape):
    # 創建一個結構為shape矩陣也可以說是數組shape聲明其行列,初始化所有值為0.1
    initial = tf.constant(0.1,shape = shape)
    return tf.Variable(initial)

def conv2d(x,W):
    # 卷積遍歷垂直、水平方向步數為1(中間的兩個1),SAME:邊緣外自動補0,遍歷相乘
    return tf.nn.conv2d(x, W, strides = [1,1,1,1], padding = 'SAME')

def max_pool_2x2(x):
    # 池化卷積結果(conv2d)池化層采用kernel大小為2*2,步數也為2,周圍補0,取最大值。數據量縮小了4倍
    return tf.nn.max_pool(x, ksize=[1,2,2,1], strides=[1,2,2,1], padding='SAME')

#  x_image又把xs reshape成了28*28*1的形狀,因為是灰色圖片,所以通道是1.作為訓練時的input,-1代表圖片數量不定 
x_image = tf.reshape(x,[-1,28,28,1])
''' 第一層卷積操作 '''

# 第一二參數值得卷積核尺寸大小,即patch,5*5,第三個參數是圖像通道數,第四個參數是卷積核的數目,代表會出現多少個卷積特征圖像;
W_conv1 = weight_variable([5, 5, 1, 32])#[卷積核的高度,卷積核的寬度,圖像通道數,卷積核個數]
# 對於每一個卷積核都有一個對應的偏置量。
b_conv1 = bias_variable([32])
# 圖片乘以卷積核,並加上偏執量,卷積結果28x28x32,集32個28*28feature map
h_conv1 = tf.nn.relu(conv2d(x_image,W_conv1) + b_conv1)#激活函數 relu,即 max(features, 0)。即將矩陣中每行的非最大值置0
# 池化結果14x14x32 卷積結果乘以池化卷積核
h_pool1 = max_pool_2x2(h_conv1)

'''第二層卷積操作'''
# 32通道卷積,卷積出64個特征 
W_conv2 = weight_variable([5, 5, 32, 64])
# 64個偏置數據
b_conv2 = bias_variable([64])
# 注意h_pool1是上一層的池化結果,#卷積結果14x14x64
h_conv2 = tf.nn.relu(conv2d(h_pool1, W_conv2) + b_conv2)
# 池化結果7x7x64
h_pool2 = max_pool_2x2(h_conv2)
# 原圖像尺寸28*28,第一輪圖像縮小為14*14,共有32張,第二輪后圖像縮小為7*7,共有64張 

'''全連接層操作'''
# 二維張量,第一個參數7*7*64的patch,也可以認為是只有一行7*7*64個數據的卷積,第二個參數代表加入一個有1024個神經元的全連接層
W_fc1 = weight_variable([7 * 7 * 64, 1024])
# 1024個偏執數據
b_fc1 = bias_variable([1024])

# 將第二層卷積池化結果reshape成只有一行7*7*64個數據# [n_samples, 7, 7, 64] ->> [n_samples, 7*7*64]
h_pool2_flat = tf.reshape(h_pool2, [-1, 7*7*64])
# 卷積操作,結果是1*1*1024,單行乘以單列等於1*1矩陣,matmul實現最基本的矩陣相乘,不同於tf.nn.conv2d的遍歷相乘,自動認為是前行向量后列向量
h_fc1 = tf.nn.relu(tf.matmul(h_pool2_flat, W_fc1) + b_fc1)
# dropout操作,減少過擬合,其實就是降低上一層某些輸入的權重scale,甚至置為0,升高某些輸入的權值,甚至置為2,防止評測曲線出現震盪,個人覺得樣本較少時很必要
# 設置神經元被選中的概率使用占位符,由dropout自動確定scale,也可以自定義,比如0.5,
keep_prob = tf.placeholder("float")
#對卷積結果執行dropout操作
h_fc1_drop = tf.nn.dropout(h_fc1, keep_prob)
'''輸出層'''
# 二維張量,1*1024矩陣卷積,共10個卷積,對應我們開始的ys長度為10
W_fc2 = weight_variable([1024, 10])
b_fc2 = bias_variable([10])

# 最后的分類,結果為1*1*10 向量化后的圖片x和權重矩陣W相乘,加上偏置b
y_conv=tf.nn.softmax(tf.matmul(h_fc1_drop, W_fc2) + b_fc2)

'''定義loss(最小誤差概率),選定優化優化loss'''
 # 定義交叉熵為loss函數  
cross_entropy = -tf.reduce_sum(y_*tf.log(y_conv)) #y_表示期望輸出,實際值
 # 調用優化器優化,其實就是通過喂數據爭取cross_entropy最小化,le-4是學習速率
#train_step = tf.train.AdamOptimizer(1e-4).minimize(cross_entropy)
train_step = tf.train.GradientDescentOptimizer(1e-3).minimize(cross_entropy)#梯度下降法

# 數據訓練及評估
#tf.argmax(y,1)返回的是模型對於任一輸入x預測到的標簽值,tf.argmax(y_,1) 代表正確的標簽
correct_prediction = tf.equal(tf.argmax(y_conv,1), tf.argmax(y_,1))# 返回的是一個布爾數組
accuracy = tf.reduce_mean(tf.cast(correct_prediction, "float"))#將布爾值轉換為浮點數,來代表對、錯,然后取平均值

saver = tf.train.Saver() #定義saver,保存模型

with tf.Session() as sess:
    # 初始化所以張量
    sess.run(tf.global_variables_initializer())
        
    '''模型訓練,測試時此部分注釋掉'''
    for i in range(20000): #迭代次數20000
        #一次訓練50個樣本
        batch = mnist.train.next_batch(50)
        if i % 100 == 0:
            train_accuracy = accuracy.eval(feed_dict={
                x: batch[0], y_: batch[1], keep_prob: 1.0})
            print('step %d, training accuracy %g' % (i, train_accuracy))
        train_step.run(feed_dict={x: batch[0], y_: batch[1], keep_prob: 0.5})
    saver.save(sess, 'model/model.ckpt') #模型儲存位置
    print('test accuracy %g' % accuracy.eval(feed_dict={
        x: mnist.test.images, y_: mnist.test.labels, keep_prob: 1.0}))
    
  '''模型測試,訓練時此部分注釋掉'''    
#    saver.restore(sess, "model/model.ckpt") #使用模型,參數和之前的代碼保持一致    
#    prediction=tf.argmax(y_conv,1)
#    while True:
#        adress = input("請輸入圖片地址:")#形如'test_num/num5.png'
#        im = Image.open(str(adress)) #讀取的圖片所在路徑,注意是28*28像素
#        plt.imshow(im)  #顯示需要識別的圖片
#        plt.show()
#        im = im.convert('L')# 轉換成灰色圖像
#        tv = list(im.getdata()) 
#        tva = [(255-x)*1.0/255.0 for x in tv] #像素值歸一化
#    
#        predint=prediction.eval(feed_dict={x: [tva],keep_prob: 1.0}, session=sess)
#        print('識別結果:')
#        print(predint[0])

參考資料

1、TF官方文檔中文版

http://wiki.jikexueyuan.com/project/tensorflow-zh/

2、《tensorflow實戰google深度學習框架》

3、《機器學習速成課程-使用TensorFlow AP》

https://developers.google.cn/machine-learning/crash-course/ml-intro

4、知乎專欄:

https://www.zhihu.com/people/bo-bo-66-27/posts


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM