介紹如何用AI辨識人的臉部情緒(Emotion AI) 第一部分

KevinLuo
10 min readNov 30, 2021

--

這個專案總共會拆分成21個tasks,圖文加上code都會附上。

那就開始吧。

任務 1:理解問題陳述和業務案例

Artificial Emotional Intelligence or Emotion AI是人工智慧的分支之一,
主要讓電腦瞭解人類非- 語言的一種溝通方式或表示情緒的方法。 如肢體語言和面部表情。

Emotion AI相關尖端技術公司

這次主要讓大家做一個臉部表情辨識的AI小專案,也讓大家了解簡單的Computer Vision.

首先,專案導覽:

  1. 這個專案的目的是根據人們的情緒來分類臉部圖像。
  2. 在此案例研究中,我們將假設您的工作是一個 AI/ML 顧問。
  3. 想像您已被一家台灣新創公司雇用來構建、培訓和部署自動監控人們情緒和表情之系統。
  4. 該團隊收集了超過 20000 張人臉圖像,他們的相關臉部表情標籤,和大約 2000 張圖像的臉部關鍵點註釋。

專案概述:Pipeline to detect keypoints & emotions:

一個臉部的Input通過兩個model的相結合,一個是Facial key points detection model, 一個是Fatial expression (emotion)detection model. 來判斷說最後這個臉部的喜怒哀樂或者其他的emotion.

第 1 部分, 關鍵臉部點檢測:

在第 1 部分中,我們將創建一個基於卷積的深度學習模型神經網絡和殘差塊來預測面部關鍵點。

Data Source: https://www.kaggle.com/c/facial-keypoints-detection/data

數據集由15個面部關鍵點的x和y坐標組成

輸入圖像為 96 x 96 像素

圖像僅包含一個color channel(灰階Gray channel)

任務 2:導入庫和數據集

# Import the necessary packages

import pandas as pd

import numpy as np

import os

import PIL

import seaborn as sns

import pickle

from PIL import *

import cv2

import tensorflow as tf

from tensorflow import keras

from tensorflow.keras.applications import DenseNet121

from tensorflow.keras.models import Model, load_model

from tensorflow.keras.initializers import glorot_uniform

from tensorflow.keras.utils import plot_model

from tensorflow.keras.callbacks import ReduceLROnPlateau, EarlyStopping, ModelCheckpoint, LearningRateScheduler

from IPython.display import display

from tensorflow.python.keras import *

from tensorflow.keras.preprocessing.image import ImageDataGenerator

from tensorflow.keras import layers, optimizers

from tensorflow.keras.applications.resnet50 import ResNet50

from tensorflow.keras.layers import *

from tensorflow.keras import backend as K

from keras import optimizers

import matplotlib.pyplot as plt

from sklearn.model_selection import train_test_split

from google.colab.patches import cv2_imshow

# load facial key points data

keyfacial_df = pd.read_csv(‘/content/Emotion AI /data.csv’)

keyfacial_df #檢視資料集

Output在這裡就不貼出來了,主要說明input的部分

# Obtain relavant information about the dataframe

keyfacial_df.info()

# Check if null values exist in the dataframe

keyfacial_df.isnull().sum()

keyfacial_df[‘Image’].shape
(2140,)

# Since values for the image are given as space separated string, separate the values using ‘ ‘ as separator.

# Then convert this into numpy array using np.fromstring and convert the obtained 1D array into 2D array of shape (96, 96)

keyfacial_df[‘Image’] = keyfacial_df[‘Image’].apply(lambda x: np.fromstring(x, dtype = int, sep = ‘ ‘).reshape(96, 96))

keyfacial_df.describe() #印出各項colunm的統計數

任務 3: 圖像視覺化

i = np.random.randint(1, len(keyfacial_df))

plt.imshow(keyfacial_df[‘Image’][i], cmap = ‘gray’)

for j in range(1, 31, 2):

plt.plot(keyfacial_df.loc[i][j-1], keyfacial_df.loc[i][j], ‘rx’)

# Let’s view more images in a grid format

fig = plt.figure(figsize=(20, 20))

for i in range(16):

ax = fig.add_subplot(4, 4, i + 1)

image = plt.imshow(keyfacial_df[‘Image’][i],cmap = ‘gray’)

for j in range(1,31,2):

plt.plot(keyfacial_df.loc[i][j-1], keyfacial_df.loc[i][j], ‘rx’)

import random

# Let’s view more images in a grid format

fig = plt.figure(figsize=(20, 20))

for i in range(64):

k = random.randint(1, len(keyfacial_df))

ax = fig.add_subplot(8, 8, i + 1)

image = plt.imshow(keyfacial_df[‘Image’][k],cmap = ‘gray’)

for j in range(1,31,2):

plt.plot(keyfacial_df.loc[k][j-1], keyfacial_df.loc[k][j], ‘rx’)

任務 4: 圖像資料擴增

# Create a new copy of the dataframe

import copy

keyfacial_df_copy = copy.copy(keyfacial_df)

# Obtain the columns in the dataframe

columns = keyfacial_df_copy.columns[:-1]

columns

# Show the Original image

plt.imshow(keyfacial_df[‘Image’][0], cmap = ‘gray’)

for j in range(1, 31, 2):

plt.plot(keyfacial_df.loc[0][j-1], keyfacial_df.loc[0][j], ‘rx’)

原來的圖示

# Show the Horizontally flipped image

plt.imshow(keyfacial_df_copy[‘Image’][0],cmap=’gray’)

for j in range(1, 31, 2):

plt.plot(keyfacial_df_copy.loc[0][j-1], keyfacial_df_copy.loc[0][j], ‘rx’)

照著X軸鏡向後的擴增圖

然後記得和之前的dataset加總起來,垂直方向我們用concatenate語法

# Concatenate the original dataframe with the augmented dataframe

augmented_df = np.concatenate((keyfacial_df, keyfacial_df_copy))

augmented_df.shape #檢查現在的shape變成(4280,31)

再度進行擴稱圖像,試著把圖片變亮方式當作augmentation方法的一種:

# Randomingly increasing the brightness of the images

# We multiply pixel values by random values between 1.5 and 2 to increase the brightness of the image

# we clip the value between 0 and 255

import random

keyfacial_df_copy = copy.copy(keyfacial_df)

keyfacial_df_copy[‘Image’] = keyfacial_df_copy[‘Image’].apply(lambda x:np.clip(random.uniform(1.5, 2)* x, 0.0, 255.0))

augmented_df = np.concatenate((augmented_df, keyfacial_df_copy))

augmented_df.shape#變亮後的shape變成(6420, 31)

# 我們一樣show出變亮後圖片長啥樣

plt.imshow(keyfacial_df_copy[‘Image’][0], cmap=’gray’)

for j in range(1, 31, 2):

plt.plot(keyfacial_df_copy.loc[0][j-1], keyfacial_df_copy.loc[0][j], ‘rx’)

變亮後的圖片

接下來倒轉他: 用np.flip()做這件事情:

keyfacial_df_copy = copy.copy(keyfacial_df)

keyfacial_df_copy[‘Image’] = keyfacial_df_copy[‘Image’].apply(lambda x: np.flip(x, axis = 0))

for i in range(len(columns)):

if i%2 == 1:

keyfacial_df_copy[columns[i]] = keyfacial_df_copy[columns[i]].apply(lambda x: 96. — float(x) )

執行健全性檢查並可視化示例圖像:

plt.imshow(keyfacial_df_copy[‘Image’][0], cmap=’gray’)

for j in range(1, 31, 2):

plt.plot(keyfacial_df_copy.loc[0][j-1], keyfacial_df_copy.loc[0][j], ‘rx’)

倒過來的augmentation data

任務 5: 資料歸一化和訓練資料準備

# 取得第 31 欄中存在的影像值(由於索引從 0 開始,因此我們引用第 31欄在python上是取到 30,而我們只需要第30欄而已,所以逗點後只取30)

img = augmented_df[:,30]

# Normalize the images

img = img/255.

# Create an empty array of shape (x, 96, 96, 1) to feed the model

X = np.empty((len(img), 96, 96, 1))

# Iterate through the img list and add image values to the empty array after expanding it’s dimension from (96, 96) to (96, 96, 1)

for i in range(len(img)):

X[i,] = np.expand_dims(img[i], axis = 2)

# Convert the array type to float32

X = np.asarray(X).astype(np.float32)

X.shape => (6420, 96, 96, 1)

#training model’s input shape equal (6420, 96, 96, 1)

下一步: 準備y (target)

#獲取用作target的 x 和 y 座標的值。

y = augmented_df[:,:30]

y = np.asarray(y).astype(np.float32)

y.shape =>(6420, 30)

再來分離訓練資料和測試資料:

# Split the data into train and test data

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size = 0.2)

X_train.shape =>(5136, 96, 96, 1) #6420乘0.8 等於1248

X_test.shape =>(1284, 96, 96, 1) #6420乘0.2 等於1248

任務 6:了解神經網路背後的理論和直覺

第 2 部分,臉部表情 (情緒)檢測

  1. 第二個模型將對人們的情緒進行分類。
  2. 數據包含屬於 5 個類別的圖像:
Ref:Challenges in Representation Learning: Facial Expression Recognition Challenge | Kaggle

介紹神經元數學模型:

Three concepts of our brain in vision:

  1. 大腦有超過 1000 億個神經元通過電和化學信號。 神經元相互交流並幫助我們看到、思考和產生想法。
  2. 人腦通過在這些神經元之間建立連接來學習。 人工神經網絡是受人腦啟發的信息處理模型。
  3. 神經元從名為樹突的輸入通道收集信號, 在它的核中處理信息,然後在核中產生輸出 一根細長的樹枝叫做軸突。
Ref:File:Neuron-no labels2.png — Wikipedia &artificial-intelligence-2228610_1920 | Many Wonderful Artists | Flickr

神經元數學模型: 簡單範例

  1. 偏差允許向上或向下移動激活函數曲線。
  2. 可調控參數的數量 有4個(3 個權重和 1 個偏差)。
  3. 激活函數為“F”。

多層感知器網絡

  1. 接著我們以多層方式連接多個這些神經元。
  2. 隱藏層越多,網絡就越“深”,網路就越複雜。

任務 7:瞭解神經網路訓練過程和梯度下降演算法

人工神經網絡(ANN)訓練和測試過程:

將資料分割為TRAINING AND TESTING Set:

  1. 數據集一般分為80%用於訓練和 20% 用於測試。
  2. 有時,我們可能包括交叉驗證數據集,然後我們可將其分成 60%、20%、20% 的部分訓練、驗證和測試,分別不同任務或狀況下(數字可能會有所不同)。

訓練集:用於梯度計算和重量更新。

驗證集:用於交叉驗證來評估訓練的質量作為訓練收益。

交叉驗證的實施是為了防止Training Model有Overfitting的情形發生。

3.測試集(給Training model的大考考卷):用於測試訓練網路。

梯度下降法GRADIENT DESCENT:

梯度下降是用於獲取優化網路權重和偏置值的優化演算法。

它通過反覆嘗試以盡量減少成本函數(cost function)來工作。

它通過計算成本函數的梯度和往負的方向移動,直到達到local/gloabal最低值(Minima)。

如果採取梯度正數,則實現lacal/global最大值。

所採取步驟的大小稱為學習率:通常以η為符號代表。

如果學習率提高,搜索空間覆蓋的區域將增加,因此我們可能可以更快地達到global minima(當然有例外這裡不細說)。

對於小學習率,訓練需要更長的時間才能達到優化的權重(一樣:不一定快就是最好,還是要看整個優化的情況)。

File:Gradient descent method.png — Wikimedia Commons
File:Gradient descent.png — Wikimedia Commons

讓我們假設想獲得最佳參數 「m」 和 「b」 。如下圖

我們需要首先制定一個loss function如下:

然後經過以下的方法進行迭代cycle:

我們定義出一個error,並且我們每次迭代都希望這個error能夠越小越好,最好是無限接近0。或是全域最低點。

梯度下降工作概念如下描述:

假設我們的loss function被定義成以下:

再來梯度下降就照著下方的步驟進行:

  1. 計算損失函數的梯度(取偏微分)

2.為權重(m、b)斜率和偏差和偏差選擇一個隨機值

3.計算步數大小(我們要更新多少)參數?

4.更新參數並重複

Note:實際上,此圖應該為 3圖D,有三個軸,一個為m,b 和殘差平方和

卷積神經網路(convolutional neural networks);Entire network overview:

File:Artificial neural network.svg — Wikimedia Commons

通過一層convolutional layer還有pooling layer後再攤平成fully connected neural network.最後輸出Facial key points prediction和Emotion prediction.

RESNET (RESIDUAL NETWORK):

隨著 CNN 越來越深,逐漸消失的梯度趨於發生,對網絡性能產生負面影響(梯度消失現象)。

梯度消失的狀況會發生在反向傳播到更前面的層造成非常小的梯度。

殘差網路(Residual Neural Network)包含"skip connection"特徵,可以有能力訓練到152層且不會有梯度消失的議題發生。

Resnet 通過在 CNN 之上添加“身份映射”來工作。

ImageNet 包含 1100 萬張圖像和 11000 個類別。

ImageNet 用於訓練 ResNet 深度網絡。

任務#9:構建深度殘差神經網路關鍵面部點檢測模型

def res_block(X, filter, stage): (請注意縮排! def後都要縮排)

# Convolutional_block

X_copy = X

f1 , f2, f3 = filter

# Main Path

X = Conv2D(f1, (1,1),strides = (1,1), name =’res_’+str(stage)+’_conv_a’, kernel_initializer= glorot_uniform(seed = 0))(X)

X = MaxPool2D((2,2))(X)

X = BatchNormalization(axis =3, name = ‘bn_’+str(stage)+’_conv_a’)(X)

X = Activation(‘relu’)(X)

X = Conv2D(f2, kernel_size = (3,3), strides =(1,1), padding = ‘same’, name =’res_’+str(stage)+’_conv_b’, kernel_initializer= glorot_uniform(seed = 0))(X)

X = BatchNormalization(axis =3, name = ‘bn_’+str(stage)+’_conv_b’)(X)

X = Activation(‘relu’)(X)

X = Conv2D(f3, kernel_size = (1,1), strides =(1,1),name =’res_’+str(stage)+’_conv_c’, kernel_initializer= glorot_uniform(seed = 0))(X)

X = BatchNormalization(axis =3, name = ‘bn_’+str(stage)+’_conv_c’)(X)

# Short path

X_copy = Conv2D(f3, kernel_size = (1,1), strides =(1,1),name =’res_’+str(stage)+’_conv_copy’, kernel_initializer= glorot_uniform(seed = 0))(X_copy)

X_copy = MaxPool2D((2,2))(X_copy)

X_copy = BatchNormalization(axis =3, name = ‘bn_’+str(stage)+’_conv_copy’)(X_copy)

# ADD

X = Add()([X,X_copy])

X = Activation(‘relu’)(X)

# Identity Block 1

X_copy = X

# Main Path

X = Conv2D(f1, (1,1),strides = (1,1), name =’res_’+str(stage)+’_identity_1_a’, kernel_initializer= glorot_uniform(seed = 0))(X)

X = BatchNormalization(axis =3, name = ‘bn_’+str(stage)+’_identity_1_a’)(X)

X = Activation(‘relu’)(X)

X = Conv2D(f2, kernel_size = (3,3), strides =(1,1), padding = ‘same’, name =’res_’+str(stage)+’_identity_1_b’, kernel_initializer= glorot_uniform(seed = 0))(X)

X = BatchNormalization(axis =3, name = ‘bn_’+str(stage)+’_identity_1_b’)(X)

X = Activation(‘relu’)(X)

X = Conv2D(f3, kernel_size = (1,1), strides =(1,1),name =’res_’+str(stage)+’_identity_1_c’, kernel_initializer= glorot_uniform(seed = 0))(X)

X = BatchNormalization(axis =3, name = ‘bn_’+str(stage)+’_identity_1_c’)(X)

# ADD

X = Add()([X,X_copy])

X = Activation(‘relu’)(X)

# Identity Block 2

X_copy = X

# Main Path

X = Conv2D(f1, (1,1),strides = (1,1), name =’res_’+str(stage)+’_identity_2_a’, kernel_initializer= glorot_uniform(seed = 0))(X)

X = BatchNormalization(axis =3, name = ‘bn_’+str(stage)+’_identity_2_a’)(X)

X = Activation(‘relu’)(X)

X = Conv2D(f2, kernel_size = (3,3), strides =(1,1), padding = ‘same’, name =’res_’+str(stage)+’_identity_2_b’, kernel_initializer= glorot_uniform(seed = 0))(X)

X = BatchNormalization(axis =3, name = ‘bn_’+str(stage)+’_identity_2_b’)(X)

X = Activation(‘relu’)(X)

X = Conv2D(f3, kernel_size = (1,1), strides =(1,1),name =’res_’+str(stage)+’_identity_2_c’, kernel_initializer= glorot_uniform(seed = 0))(X)

X = BatchNormalization(axis =3, name = ‘bn_’+str(stage)+’_identity_2_c’)(X)

# ADD

X = Add()([X,X_copy])

X = Activation(‘relu’)(X)

return X

#關鍵面部點檢測模型,和查看模型summary

input_shape = (96, 96, 1)

# Input tensor shape

X_input = Input(input_shape)

# Zero-padding

X = ZeroPadding2D((3,3))(X_input)

# 1 — stage

X = Conv2D(64, (7,7), strides= (2,2), name = ‘conv1’, kernel_initializer= glorot_uniform(seed = 0))(X)

X = BatchNormalization(axis =3, name = ‘bn_conv1’)(X)

X = Activation(‘relu’)(X)

X = MaxPooling2D((3,3), strides= (2,2))(X)

# 2 — stage

X = res_block(X, filter= [64,64,256], stage= 2)

# 3 — stage

X = res_block(X, filter= [128,128,512], stage= 3)

# Average Pooling

X = AveragePooling2D((2,2), name = ‘Averagea_Pooling’)(X)

# Final layer

X = Flatten()(X)

X = Dense(4096, activation = ‘relu’)(X)

X = Dropout(0.2)(X)

X = Dense(2048, activation = ‘relu’)(X)

X = Dropout(0.1)(X)

X = Dense(30, activation = ‘relu’)(X)

model_1_facialKeyPoints = Model( inputs= X_input, outputs = X)

model_1_facialKeyPoints.summary()

任務 10:編譯和訓練關鍵臉部點檢測深度學習模型

adam = tf.keras.optimizers.Adam(learning_rate = 0.0001, beta_1 = 0.9, beta_2 = 0.999, amsgrad = False)

model_1_facialKeyPoints.compile(loss = “mean_squared_error”, optimizer = adam , metrics = [‘accuracy’])

# Check this out for more information on Adam optimizer: https://www.tensorflow.org/api_docs/python/tf/keras/optimizers/Adam

# save the best model with least validation loss

checkpointer = ModelCheckpoint(filepath = “FacialKeyPoints_weights.hdf5”, verbose = 1, save_best_only = True)

history = model_1_facialKeyPoints.fit(X_train, y_train, batch_size = 32, epochs = 2, validation_split = 0.05, callbacks=[checkpointer])

# save the model architecture to json file for future use

model_json = model_1_facialKeyPoints.to_json()

with open(“FacialKeyPoints-model.json”,”w”) as json_file:

json_file.write(model_json)

任務 11:評估經過訓練的關鍵臉部點檢測模型性能,並視覺化其成果

with open(‘detection.json’, ‘r’) as json_file:

json_savedModel= json_file.read()

# load the model architecture

model_1_facialKeyPoints = tf.keras.models.model_from_json(json_savedModel)

model_1_facialKeyPoints.load_weights(‘weights_keypoint.hdf5’)

adam = tf.keras.optimizers.Adam(learning_rate=0.0001, beta_1=0.9, beta_2=0.999, amsgrad=False)

model_1_facialKeyPoints.compile(loss=”mean_squared_error”, optimizer= adam , metrics = [‘accuracy’])

# Evaluate the model

result = model_1_facialKeyPoints.evaluate(X_test, y_test)

print(“Accuracy : {}”.format(result[1]))

# Get the model keys

history.history.keys()

# Plot the training artifacts

plt.plot(history.history[‘loss’])

plt.plot(history.history[‘val_loss’])

plt.title(‘Model loss’)

plt.ylabel(‘loss’)

plt.xlabel(‘epoch’)

plt.legend([‘train_loss’,’val_loss’], loc = ‘upper right’)

plt.show()

第 2 部分,臉部表情 (情緒)檢測

  1. 第二個模型將對人們的情緒進行分類。
  2. 數據包含屬於 5 個類別的圖像:
Ref:Challenges in Representation Learning: Facial Expression Recognition Challenge | Kaggle

任務12:導入和探索用於臉部表情檢測的數據集

# read the csv files for the facial expression data

facialexpression_df = pd.read_csv(‘icml_face_data.csv’)

facialexpression_df =>#(24568 rows × 2 columns)

facialexpression_df[‘ pixels’][0] # String format

# function to convert pixel values in string format to array format

def string2array(x):

return np.array(x.split(‘ ‘)).reshape(48, 48, 1).astype(‘float32’)

# Resize images from (48, 48) to (96, 96)

def resize(x):

img = x.reshape(48, 48)

return cv2.resize(img, dsize=(96, 96), interpolation = cv2.INTER_CUBIC)

facialexpression_df[‘ pixels’] = facialexpression_df[‘ pixels’].apply(lambda x: string2array(x))

facialexpression_df[‘ pixels’] = facialexpression_df[‘ pixels’].apply(lambda x: resize(x))

facialexpression_df.head()

# check the shape of data_frame

facialexpression_df.shape =>(24568, 2)

# check for the presence of null values in the data frame

facialexpression_df.isnull().sum()

label_to_text = {0:’anger’, 1:’disgust’, 2:’sad’, 3:’happiness’, 4: ‘surprise’}

視覺化數據中的第一個圖像,並確保圖像不會因調整大小或重塑操作而失真

plt.imshow(facialexpression_df[‘ pixels’][0], cmap = ‘gray’)

指標:混淆矩陣

定義和關鍵績效指標

混淆矩陣用於描述一個分類模型:

真陽性 (TP):分類器預測為 TRUE 的情況(它們有 疾病),並且正確的類別為 TRUE(患者患有疾病)。

真陰性 (TN):模型預測為 FALSE(無疾病)的情況, 正確的類別是 FALSE(患者沒有疾病)。

誤報 (FP)(I 類錯誤):分類器預測為 TRUE,但 正確的類別是 FALSE(患者沒有疾病)。

假陰性 (FN)(II 類錯誤):分類器預測 FALSE(患者 沒有病),但他們確實有病。

Classification Accuracy = (TP+TN) / (TP + TN + FP + FN)

Misclassification rate (Error Rate) = (FP + FN) / (TP + TN + FP + FN)

Precision = TP/Total TRUE Predictions = TP/ (TP+FP)(當模型 預測的 TRUE 課程,正確的頻率是多少?)

Recall = TP/ Actual TRUE = TP/ (TP+FN)(當課程實際上是 是的,分類器多久做對一次?

PRECISION Vs. RECALL EXAMPLE

只有精度(Accuracy)一般會造成誤導而且遠遠不夠來評估 一個分類器的績效。

召回率(Recall)是一個重要的 KPI ,當數據集極度不平衡;當TN極度大於TP之時,這時候Recall(TP/ (TP+FN)=1/9)就相對比較有參考度。

看以下混淆矩陣情形的各個KPI是多少:

極度不平衡的數據,需要多種KPI來探討

分類準確率=(TP+TN)/(TP+TN+FP+FN)=91%

精度 = TP/總正確預測 = TP/ (TP+FP) = ½=50%

召回 = TP/ 實際 TRUE = TP/ (TP+FN) = 1/9 = 11%

用Tensorflow來進行模型部屬:

假設我們已經訓練了模型,並且它在測試數據上生成了良好的結果。

現在,我們希望將經過訓練的 Tensorflow 模型整合到 Web 應用中,並將模型部署到生產級環境中。

以下目標可使用 Tensorflow服務獲得。 Tensorflow服務是一個高階機器學習模型的服務系統,專為生產環境設計。

在TensorFlow Serving的幫助下,我們可以輕鬆部署新的演算法來進行預測。
為了使用 TensorFlow Serving 為訓練的模型提供服務,我們需要以適合使用 TensorFlow Serving 服務的格式保存模型。

模型將具有version number,並將保存在結構化目錄中。

在保存模型后,我們就可以使用 TensorFlow Serving 開始使用特定版本的訓練模型進行推理請求: “可接受服務的”狀態。

跑Tensorflow 服務:

這裡有一些重要的參數需要提及:

rest_api_port:將用於 REST 請求的port.

model_name:您將在 REST 請求的 URL 中使用它。您可以選擇任何名稱。

model_base_path:這是您保存的目錄的路徑您的模型。

--

--

KevinLuo

知曉很多種資料處理,可BI或AI化的軟體和工具。主要用的程式語言是python和R 偶爾用C++ Ig:(可在上面找到我) AIA第九屆經理人班 立志當個厲害的podcaster!