Swift [第5單元] 人工智慧程式基礎

第8課 自然語言處理(5-8a)

什麼是自然語言處理(Natural Language Processing, NLP)?

在上一節(5-7c)我們利用Apple語音辨識與ChatGPT API做出人機對話,讓使用者開口提問,然後由程式回答,一問一答,如此反覆。程式本身不難,最難之處其實在於問題的答覆,筆者曾問一個問題:

🗣 "假如你是一位高中老師,相較於數學、英文、自然等科目,你要如何對學生表達程式設計獨特的重要性?"

這個問題想點出目前台灣程式設計教育的困境,程式設計目前雖然列為高中必修,但因為不像國、英、數這些升學考試科目,並沒有得到學生或家長應有的重視。然而,隨著人工智慧的迅速發展,加上資訊產品對生活滲透率越來越高,程式設計做為跨領域學習最佳工具,其必要性已迫在眉梢。

這個問題顯然沒有標準答案,每個人會有不同看法,也不是簡單的 Yes or No 或用關鍵字搜尋得到的。我們來看 ChatGPT 如何回答:

💡 "作為一位高中老師,我認為程式設計的獨特重要性在於它是一門全新的、革命性的學科,它把傳統的數學、英文、自然等科目融合在一起,形成了一個全新的學科體系。

首先,程式設計是一門有用的學科,它能夠幫助學生解決現實生活中的問題。現代社會越來越依賴科技和信息技術,而學習程式設計可以讓學生對科技和信息技術的發展有更深入的理解,並且可以用所學知識來創造新的技術產品,解決現實生活中的問題。

其次,程式設計是一門培養學生創新思維的學科。在程式設計的過程中,學生需要不斷創新,提出新的思路和解決方法,這能夠培養學生的創新思維和創造力。

最後,程式設計是一門有前途的學科。信息技術產業是當今社會發展速度最快、最具前景的行業之一,學習程式設計可以為學生未來的發展打下堅實的基礎,有助於他們成為有競爭力的人才。

綜上所述,我認為程式設計是一門獨特且重要的學科,學生應該認真學習,充分發揮它的優勢,為未來的發展奠定良好的基礎。"

仔細閱讀之後,不得不說,ChatGPT 的回答相當有說服力,非常有「人味」。這顯然不是單純把文字湊在一起而已,有幾點是一般 AI 不容易做到的:

1. 回答十分切題,這必須對問題的語義要有所理解。
2. 條理分明,彼此連貫,絕不是簡單的複製-貼上能做到的。
3. 邏輯嚴密,沒有明顯的錯誤或可辯駁的漏洞。

這是怎麼做到的呢?難道機器已經能夠思考了嗎?

這就是本課「自然語言處理」(NLP)的範疇,雖然本課無法學到 ChatGPT 背後的原理,但是學個起步是可以的。

從上面這個問答範例可以看出,要正確回答問題,首先必須對「問題」有所了解,不能誤解語義,才能判斷哪些答案在問題範圍內,哪些答案與問題無關。

如何教機器理解人類語言的含義呢?這是個非常困難的挑戰,也是當初(約60年前)人工智慧發展「自然語言處理」的最初目標 — 希望有朝一日,機器能聽懂人話,並且對答流利。我們何其幸運,能親身見證這個夢想成真。

隨著近年 AI 技術的發展,讓機器理解語義,現在只算是基礎功夫,由此而上,可發展出許多應用,例如這兩年非常熱門的「AI 繪圖」,以及 AI 寫文章、作曲、翻譯…等等,甚至以後 AI 可以寫劇本、拍電影!這些都是從理解語義開始。

5-8a 中文斷詞(NLTokenizer)

自然語言處理初期的發展,是從「語法分析」開始,語法(syntax)是比語義(symantics)更基本的語言規則,也就是在一個句子中,區分出主詞、動詞、受詞、介系詞…等基本單位,這個動作稱為「斷詞」(word segmentation),對東方語系(中、日、韓、阿拉伯語等)來說特別困難,是人工智慧初期的第一道關卡。

對英文(或所有拉丁語系)而言,語法規則相對單純,只要前後是空格、標點(除了連字符號 hyphen -)所隔開的就是一個單字(word),但是對中文來說,卻沒那麼容易,例如:

「全台大露營活動明天開始報名」

這句話的主詞是「全台大露營活動」,「明天」是時間副詞,「開始報名」是動詞。其中,主詞有兩種含義,一是「全台/大露營/活動」,二是「全台大/露營/活動」,得視上下文而定。

對電腦而言,要如何斷詞呢?理想的AI,應該要先斷成兩子句:

「全台大露營活動」
「明天開始報名」

再斷詞:

「全台/大露營/活動」或「全台大/露營/活動」
「明天/開始/報名」

我們用實際程式來測試看看。

本節先學習 Apple 原廠的 NaturalLanguage 自然語言框架(物件名稱以NL開頭)其中兩個基本物件:

1. NLLanguageRecognizer — 語言辨識器,用來判別文字的主要語言
2. NLTokenizer — 斷詞器,對字串進行斷句或斷詞

其中,token 的字面意思是「代幣」「令牌」或其他有象徵意義的替代事物,在自然語言處理的領域中,token 代表語法分析後的最小單位,也就是斷詞後的每個部分。例如上述「全台/大露營/活動」,包含三個 token — 「全台」「大露營」「活動」,這也是上節課 ChatGPT API收費的計算單位(每 1000 tokens 計費 0.002美元)。

我們用以下範例,對「全台大露營活動明天開始報名」這句話進行兩個動作:(1) 偵測主要語言 (2) 斷詞。
// 5-8a 自然語言處理(1):中文斷詞 NLTokenizer
// Created by Heman, 2023/05/18
import NaturalLanguage

let 文本 = "全台大露營活動明天開始報名"

// 第一段:辨識主要語言
guard let 主要語言 = NLLanguageRecognizer.dominantLanguage(for: 文本) else {
print("Error: 無法判斷主要語言")
exit(-1)
}
print(主要語言)

// 第二段:斷詞
let 斷詞器 = NLTokenizer(unit: .word)
斷詞器.setLanguage(主要語言)
斷詞器.string = 文本

// 第三段:列印
let 全文範圍 = 文本.startIndex ..< 文本.endIndex
let 索引陣列 = 斷詞器.tokens(for: 全文範圍)
for i in 索引陣列 {
print(文本[i])
}

注意程式中用到字串的索引,和一般陣列索引方式不同,字串的索引用法請參考第4單元第3課4-3c

在主控台可以觀察到,主要語言判斷為繁體中文(zh-Hant),斷詞結果如下:
NLLanguage(_rawValue: zh-Hant)

台大
露營
活動
明天
開始
報名

看來「全台」或「台大」兩種斷詞方式,程式認為「台大」機率比較高。


💡 註解
  1. 嚴格來說,所謂「自然語言處理」,處理的對象其實是文字;語言與文字含義稍有不同,從發展歷史來看,人類先有語言,後有文字,文字用來記錄語言。
  2. 但是到了現代,同一種文字所對應的,可能不只一種語言,例如日語中的漢字,與中文用的其實是相同文字(同一個Unicode編碼);又如英語、法語、德語大部分文字是相同的拉丁字母;俄語、斯拉夫語與中亞許多語言都採用西里爾文字(Cyrillic)…等等。所以程式中必須指定何種語言所用的文字。
  3. 上節課所提到的「金鑰」(key),在某些原文文件中,也稱為 token (令牌/權仗)。總之,token 就是某種象徵的替代物,例如令牌的意義就是替代本人(發號施令或認證)的物品。
  4. 在上一課5-7b ChatGPT API的範例中,有沒有注意到我們在提問時並未指定語言,顯然ChatGPT 會自動判別字串的主要語言,類似NLLanguageRecognizer的功能。
  5. 若不侷限於Apple的NaturalLanguage框架,一般而言,中文斷詞並沒有一定標準,以「全台大露營」為例,斷詞為「全台/大/露營」、「全/台/大/露營」、「全/台大/露營」都有可能。
  6. 斷詞背後需要詞組資料庫,目前中文斷詞最常用的開源詞庫為中央研究院CKIP、台北醫學大學MONPA、中國大陸的Jieba(結巴)等三種。
  7. 有個小地方,在本課一開始的ChatGPT回答中,使用「信息技術」一詞,乃是大陸的用語,台灣通常用「資訊科技」(兩者都是 Information Technology 的翻譯),可見ChatGPT背後的詞庫包含大陸詞彙的比例很可能超過台灣。
5-8b 中文斷詞+語音合成

我們在第4單元第3課「字串分解」曾提過,Swift程式可以輕易處理中文字串,包括分行、斷句、解字都很容易,唯有斷詞最困難,必須借助人工智慧才有辦法,現在終於知道怎麼做了。

在上一節可以看出,中文斷詞看似平凡無奇,卻是整個自然語言處理的基本功,若斷詞做得精準,人工智慧的應用就會更實用。以中文輸入法為例(筆者用注音輸入法),和十年前相比,現在中文輸入變得順暢許多,很少需要重新選字,其實就是受惠於此。

另一個受惠於中文斷詞的應用,就是語音合成。在過去,語音合成唸出來的句子非常機械化,因為語速固定沒有變化,雖然聽得懂,但一聽就知道是機器講的話。

因為真人講話需要換氣,通常會唸完一個詞或整句之後再換氣,所以講話語速不固定,是有快慢節奏的,要讓機器模仿這樣的節奏,就必須懂得斷詞斷句,否則該停頓的地方不停,不該停頓的地方隨意停,反而讓人聽得一頭霧水,例如試唸一下「全台大露/營活動明/天開/始報/名」就知道。

以下我們試著中文斷詞後再接語音合成,讓機器朗讀的速率有明顯變化,比較一下和整句朗讀有何不同:
// 5-8b 自然語言處理(2): 斷詞+朗讀
// Created by Heman, 2023/05/20
import NaturalLanguage
import AVFoundation

//let 文本 = "Apple today announced it will host its annual Worldwide Developers Conference (WWDC) in an online format from 5 until 9 June (PDT), 2023.😀"
//let 文本 = "Appleは今年の開発者会議イベント「WWDC 2023」を米国西海岸で6月5日から9日までオンラインで開催する予定だ。😀"
//let 文本 = "Apple은 올해 개발자 컨퍼런스 이벤트 WWDC 2023을 6월 5일부터 9일까지 미국 서부 해안에서 온라인으로 개최할 예정입니다.😀"
let 文本 = "蘋果公司預計在美國西岸時間6月5日至9日期間,以線上形式舉辦今年度的開發者大會活動WWDC 2023😀"

// 第一段:辨識主要語言
guard let 語言 = NLLanguageRecognizer.dominantLanguage(for: 文本) else {
print("Error: 無法判斷主要語言")
exit(-1)
}
print(語言)

// 第二段:斷詞
let 斷詞器 = NLTokenizer(unit: .word)
斷詞器.setLanguage(語言)
斷詞器.string = 文本

// 第三段:整句朗讀
let 語音合成器 = AVSpeechSynthesizer()
let 朗讀內容 = AVSpeechUtterance(string: 文本)
朗讀內容.voice = AVSpeechSynthesisVoice(language: 語言.rawValue)
語音合成器.speak(朗讀內容)

// 第四段:斷詞朗讀
let 全文範圍 = 文本.startIndex ..< 文本.endIndex
斷詞器.enumerateTokens(in: 全文範圍) { range, attribute in
print(文本[range], attribute.rawValue)
let 朗讀內容 = AVSpeechUtterance(string: String(文本[range]))
朗讀內容.voice = AVSpeechSynthesisVoice(language: 語言.rawValue)
語音合成器.speak(朗讀內容)
return true
}

import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true

這次我們斷詞的方式改用 enumerateTokens() 物件方法,enumerate 是動詞「列舉」「窮舉」「逐一…」的意思,所以 enumerateTokens() 相當於上一節的 tokens() + for 迴圈的結合。

enumerateTokens() 會傳遞兩個參數到後面匿名函式(相當於迴圈)中,range 是字串的索引範圍,表示該詞的位置;attribute 是該詞的屬性,屬性編碼為:

0 — 一般文字,如「蘋果」
1 — 數字(Numeric),如“2023”
2 — 非字形符號(Symbolic),如 ❖ ➭ ©
3 — ? (文件未說明)
4 — 表情符號(Emoji),如 😀 ⌚️ ❤️ ⚽️

我們特別在文本最後加個表情符號😀,猜猜看會怎麼唸?


💡 註解
  1. 試試看「文本」改用其他語言,觀察語言辨識、斷詞與朗讀結果。
5-8c 詞性標注(NLTagger)

有了斷詞為基礎之後,自然語言處理的第二步是詞性的分析或分類並加以標注,一般稱為詞性標注。斷詞與詞性標注都是屬於語法上的分析,目的是幫助人工智慧進一步了解語義。

在Apple原廠NaturalLanguage 框架中,用來做詞性標注的物件為NLTagger,這是一個多功能物件,標注的範圍可以是針對整篇文章(document)、段落(paragraph)、句(sentence)或詞(word),再個別標示以下6種屬性:
  1. 語言種類(language) — 標注其語言或文字(script)種類
  2. 單詞種類(tokenType) — 標注為一般字詞、標點、空白…等
  3. 情感分析(sentimentScore) — 介於 -1.0 ~ 1.0 之間,-1 代表最負面,1.0 代表最正面
  4. 文法屬性(lexicalClass) — 標注為名詞、動詞、形容詞…等
  5. 詞幹(lemma) — 指(拉丁語系)單字的原型,如 stories → story, forgotten → forget
  6. 名詞分類(nameType) — 標注為人名、地名、公司組織名…等

不過可惜的是,後4種(情感分析、文法屬性、詞幹、名詞分類)Apple框架目前不支援中文。故範例程式分別以中、英文字串導入 NLTagger,測試前4項屬性(語言種類、單詞種類、情感分析、文法屬性)。

使用NLTagger 對任意字串加以分解並標注詞性需要三個步驟。

第一步先產出一個 NLTagger 標注器物件實例:
import NaturalLanguage

let 標注器 = NLTagger(tagSchemes: [.language])

參數 tagSchemes 用來指定標注的六種類別,可以多選(放入陣列中),參數值分別為:
  1. .language/.script 語言種類/文字種類
  2. .tokenType 單詞種類
  3. .sentimentScore 情感分析
  4. .lexicalClass 文法屬性
  5. .lemma 詞幹(Stem form)
  6. .nameType 名詞分類

第二步指定分析的文本,文本字串可以很長,一篇文章或整本書放進來都沒問題:
let 中文文本 = "請簡述日本明治維新與中國戊戌變法的異同。"
標注器.string = 中文文本

第三步使用 tag() 或 enumerateTags() 對文本進行標注。tag() 是單次標注,可針對整篇文章(document)、段落(paragraph)、句(sentence)或詞(word)等不同範圍。範例如下:
let (標注結果, 索引範圍) = 標注器.tag(at: 中文文本.startIndex, unit: .document, scheme: .language)
if let 語言 = 標注結果?.rawValue {
print("\(中文文本)\n➥語言:\(語言)")
}

注意 tag() 函式的回傳值,可同時指定給兩個變數(用小括號括起來),「 (標注結果, 索引範圍)」,這種寫法稱為多元組(Tuple),意思是將兩個以上(相同或不同類型)元素組合在一起,與 struct 類似。

在這裡我們要的是第一個變數「標注結果」,取出其 rawValue(原始值)即可獲得所標注的語言,這與 NLLanguageRecognizer 辨識的結果相同,表示 NLTagger 內部有用到 NLLanguageRecognizer。

第三步的另一種用法是enumerateTags(),與上一節的enumerateTokens()類似,enumerateTags() 相當於 tag()+for迴圈,會送兩個參數(取名為「標注結果, 索引範圍」)到匿名函式(相當於for迴圈)裡面,範例如下:
let 全文範圍 = 中文文本.startIndex ..< 中文文本.endIndex
標注器.enumerateTags(in: 全文範圍, unit: .word, scheme: .tokenType) { 標注結果, 索引範圍 in
if let 詞性 = 標注結果?.rawValue {
let 單詞 = String(中文文本[索引範圍]) + " (\(詞性))"
print(單詞)
}
return true
}

這次的標注範圍是 .word (斷詞),所以內部也一定用到NLTokenizer,如此一來,也省得我們麻煩,直接將斷詞與詞性標注一次搞定。

根據原廠文件,enumerateTags() 的匿名函式最後一行要 return true,作用不明。

重複以上步驟,完整的範例程式列表如下,分為四段:
// 5-8c 自然語言處理(3): 詞性標注
// Created by Heman, 2023/05/24
import NaturalLanguage

let 中文文本 = "請簡述日本明治維新與中國戊戌變法的異同。"

// 第一段:用NLTagger辨識主要語言(支援中文)
let 標注器1 = NLTagger(tagSchemes: [.language])
標注器1.string = 中文文本
let (標注結果, 索引範圍) = 標注器1.tag(at: 中文文本.startIndex, unit: .document, scheme: .language)
if let 語言 = 標注結果?.rawValue {
print("\(中文文本)\n➥語言:\(語言)")
print(索引範圍)
}

let 英文文本 = "Will you still love me tomorrow?"

// 第二段:用NLTagger分析情感(不支援中文)
let 標注器2 = NLTagger(tagSchemes: [.sentimentScore])
標注器2.string = 英文文本
let (標注結果2, 索引範圍2) = 標注器2.tag(at: 英文文本.startIndex, unit: .paragraph, scheme: .sentimentScore)
if let 情感分數 = 標注結果2?.rawValue {
print("\(英文文本)\n➥sentimentScore=\(情感分數)")
}

// 第三段:用NLTagger標注單詞種類(支援中文)
let 標注器3 = NLTagger(tagSchemes: [.tokenType])
標注器3.string = 中文文本
let 全文範圍 = 中文文本.startIndex ..< 中文文本.endIndex
標注器3.enumerateTags(in: 全文範圍, unit: .word, scheme: .tokenType) { 標注結果, 索引範圍 in
if let 詞性 = 標注結果?.rawValue {
let 單詞 = String(中文文本[索引範圍]) + " (\(詞性))"
print(單詞)
}
return true
}

// 第四段:用NLTagger標注文法屬性(不支援中文)
let 標注器4 = NLTagger(tagSchemes: [.lexicalClass])
標注器4.string = 英文文本
let 全文範圍2 = 英文文本.startIndex ..< 英文文本.endIndex
標注器4.enumerateTags(in: 全文範圍2, unit: .word, scheme: .lexicalClass) { 標注結果, 索引範圍 in
if let 文法屬性 = 標注結果?.rawValue {
let 單字 = String(英文文本[索引範圍]) + " (\(文法屬性))"
print(單字)
}
return true
}

執行結果如下圖:


💡 註解
  1. tag 字面意思是(實體的)「標籤」,例如 price tag (價格標籤)或 AirTag (Apple產品),意思和 label 差不多;tag 也可以當作動詞,「加上標籤」的意思。
  2. 在Swift語言中,Tuple 不只可以當作雙元組,任意多元的組合都可以稱為Tuple。
  3. Tuple 類似 struct 定義的多元資料結構,只是沒有定義欄位名稱,甚至不需要宣告。
  4. 作業:請自行更換「文本」內容,試著將整篇文章放進來測試看看,甚至改成讀取本機檔案或網路論壇文章,加以分析。
語法說明:Tuple(多元組)資料類型

Tuple 並不是普通英文單字,而是數學及程式設計領域的術語,很多初學者第一次接觸這個名詞時,容易感到疑惑不解。

Tuple 字尾源自拉丁文 plus (多數),與 double(兩倍)、triple(三倍)、quadruple(四倍)、quintuple(五倍)字尾同源。所以 Tuple 有「多」的含義,在 Swift 中與 struct 功能相近,但 Tuple 並非定義資料類型的關鍵字,而是對某種資料類型的稱呼,中文可譯為「多元資料組」或簡稱「多元組」、「元組」。

這種資料類型我們在第3、第4單元其實早已用過,只是當時並未解釋,例如:
let (data, response) = try await URLSession.shared.data(from: myURL)

let (內容, 回應碼) = try await URLSession.shared.data(from: myURL)

就跟學習語言一樣,直接練習對話,有時比先學文法還容易。Tuple 就是如此,直接使用即使不解釋,憑直覺也能懂。

上面用小括號括起來的一組兩個變數 (data, response) 或 (內容, 回應碼) 就是 Tuple,只有兩個元素(或欄位)故可稱為「二元組」,用來將兩個不同類型(也可相同類型)的資料臨時組合在一起,當作一個新的資料類型看待。

有了 Tuple,函式要回傳多種資料時就方便多了。例如,若我們要寫一個函式,回傳 x, y, z 三度空間座標,過去我們需要先用 struct 定義一個新類型讓函式回傳,現在可以這樣寫:
// Tuple 第一種用法:
func 三度空間座標() -> (Double, Double, Double) {
return (0.1, -1.2, 3.2)
}
// 函式回傳值用法一:
let (x, y, z) = 三度空間座標()
print(x, y, z)
// 函式回傳值用法二:
let 座標 = 三度空間座標()
print(座標.0, 座標.1, 座標.2) // 直接用 0, 1, 2... 當作欄位名稱

其中 (Double, Double, Double) 可稱為三元組的 Tuple,另一種寫法也可註明元素名稱(或稱元素標籤),改為 (x: Double, y: Double, z: Double),例如:
// Tuple 第二種用法:
func 三度空間座標() -> (x: Double, y: Double, z: Double) {
return (0.1, -1.2, 3.2)
}
// 函式回傳值用法:
let 座標 = 三度空間座標()
print(座標.x, 座標.y, 座標.z) // 用元素標籤當作欄位名稱

第二種用法就很接近 struct,相當於:
// 相當於 Tuple 第二種用法:
struct Point3D {
let x: Double
let y: Double
let z: Double
}
func 三度空間座標() -> Point3D {
return Point3D(x: 0.1, y: -1.2, z: 3.2)
}
// 用法:
let 座標 = 三度空間座標()
print(座標.x, 座標.y, 座標.z)

由此可見,Tuple 可以看成是簡化的 struct,省略了類型宣告與欄位名稱,直接用 () 來定義新類型。

有趣的是,Tuple 類型也可以取名稱,完全模擬 struct 所定義的類型:
typealias Point3D = (x: Double, y: Double, z: Double)

func 三度空間座標() -> Point3D {
return Point3D(x: 0.1, y: -1.2, z: 3.2)
}

這裡用 typealias 幫某類型取個別名,alias 是別名的意思。

總結來說,如果臨時需要組合一個新類型(重複用的機會不大時),用 Tuple 比用 struct 方便多了,直接用小括號 () 就可定義。

以下是 Tuple 的幾個重要特性:
  1. 元素是有次序的,可用 0, 1, 2, … 當作預設欄位名稱
  2. 元素名稱(或稱標籤)可有可無
  3. 不同元素可相同類型,也可不同類型
  4. Tuple 資料類型是所謂的 “value type”,也就是元素是由 let 定義,初始化之後不能再變更。
  5. Tuple 可以只有一個甚至零個元素,零個元素的 Tuple 記為空括號 (),還記得當一個函式不需要任何參數時,仍然要寫「函式名稱()」,空括號 () 就是零元素的 Tuple,在 Swift 中零元素的Tuple 類型又稱為 Void。

💡 註解
  1. 最早使用 Tuple 的程式語言應該是 LISP,Tuple 在 LISP 中稱為 List (串列),是 LISP 的主要資料類型,甚至 LISP 原名就是 “List Processing” 的縮寫。
  2. 發明 LISP 的約翰·麥卡錫(John McCarthy,亦被尊為人工智慧之父)是一位數學家,從數學來看,List 是集合的擴展(集合的元素須相同類型,List 元素可不同類型),而集合論又是衍生自整數論,因此 Tuple/List 有很深厚的數學理論作為基礎。
  3. 在大部分程式語言中,Tuple 通常是固定長度(長度指元素個數),而 List 則是長度可變。
  4. 注意這裡的 List 指串列資料類型,與SwiftUI 的 List (視圖物件)無關。
5-8d 追尋語義(NLEmbedding)

前面幾節所學的斷詞與詞性標注,是在語法上進行分析,較浮於表面,自然語言處理(NLP)終究還是要深入語義,才能讓機器聽懂人話,但是要如何分析語義呢?這個問題困住所有專家長達50年,直到最近10年才有所突破。

突破的方向,是設法將語義問題轉成數學問題,這樣才有辦法藉助電腦「善於計算」的能力,幫我們解決難題。但是語義非常抽象,要如何將語義轉化成可以計算的數值呢?經過多年的努力,目前已經有很多做法,例如計算某個詞出現頻率、位置、前後關係(上個詞/下個詞)、名詞種類、區分性別…等等,有非常多種方法。

其中一種做法是將一個詞轉換成一組數字,再組成一個向量 [w₁, w₂, w₃,…w₉₉, …],代表這個詞在空間中的位置,稱為詞向量(Word Vector)。兩個不同詞的詞向量不會完全一樣,但若兩者在空間中距離越短,就可能表示語義也越近,這個方法稱為 Word Embedding。

Word Embedding 是透過上下文關係,想找出詞如何嵌入到一個句子裡,embed 是「嵌入」動詞,或可想像將詞嵌入一個多維度空間中,中文依照字面可譯為「詞嵌入」。

詞嵌入(Word Embedding)是近年追尋語義的新起點,不過這仍是一個迂迴且充滿挑戰的旅程,還好在 ChatGPT 發表之後,讓所有人隱約看到目標,雖然高高在上難以攀登,但並非不可能。

在 Apple 原廠的 NaturalLanguage 框架中,NLEmbedding 就是詞嵌入的物件,可惜的是,目前此物件能力有限,似乎還無法處理中文,因此以下範例以英文單字為例,感覺一下詞嵌入的做法。

NLEmbedding 基本用法如下,可以將單字/單詞(字串)轉換成詞向量(數值):
import NaturalLanguage

if let 詞嵌入 = NLEmbedding.wordEmbedding(for: .english) {
if let 詞向量 = 詞嵌入.vector(for: "apple") {
print(詞向量, 詞向量.count)
}
}

第一步要產出 NLEmbedding 物件實例,可透過兩個類型方法(type method):詞嵌入 wordEmbedding() 或句嵌入 sentenceEmbedding(),並指明語言。若是尚不支援的語言(如 .traditionalChinese 或 .simplifiedChinese),則回傳 nil。

產出物件實例之後,第二步即可用 vector() 將詞轉為詞向量,詞向量是一個實數陣列,元素為實數,介於 -1.0 到 1.0 之間(經過餘弦函數正規化),Apple 詞嵌入轉換後的詞向量高達 300 維度(陣列內含300個元素)。

接下來可以比較兩個詞之間的距離(相似度),以間接獲取詞義:
let 語義距離1 = 詞嵌入.distance(between: "apple", and: "banana")
let 語義距離2 = 詞嵌入.distance(between: "apple", and: "table")
print(語義距離1, 語義距離2)

執行結果,”apple”與”banana”距離是0.82,”apple”與”table”距離是1.24,顯然相較於桌子,香蕉更近似於蘋果。注意這裡的距離,並非在空間中的直線距離,而是經過轉換,最後會落到0.0(完全一樣)到2.0(完全沒有相似之處)之間。

詞嵌入的另一個功能是 enumerateNeighbors() 列舉出鄰近類似的詞,語法類似上節的 enumerateTags(),會帶兩個參數進入列舉迴圈中 — (1) 近似詞 (2) 語義距離:
詞嵌入.enumerateNeighbors(for: "apple", maximumCount: 20) { 近似詞, 語義距離 in
print("\(近似詞): \(語義距離)")
return true
}

此例執行結果會列出與 “apple” 最接近的 20 個詞彙,在 Apple 原廠的語庫中,與蘋果(apple)最接近的詞彙竟然不是桃子(peach),而是藍莓(blueberry)。

以下是完整的範例程式:
// 5-8d 追尋語義 NLEmbedding
// Created by Heman, 2023/05/30
import NaturalLanguage

if let 詞嵌入 = NLEmbedding.wordEmbedding(for: .english) {
let 單詞 = "apple"

if let 詞向量 = 詞嵌入.vector(for: 單詞) {
print(詞向量, 詞向量.count)
}

let 語義距離1 = 詞嵌入.distance(between: 單詞, and: "banana")
let 語義距離2 = 詞嵌入.distance(between: 單詞, and: "table")
print(語義距離1, 語義距離2)

詞嵌入.enumerateNeighbors(for: 單詞, maximumCount: 20) { 近似詞, 語義距離 in
print("\(近似詞): \(語義距離)")
return true
}
}

let 中文詞嵌入 = NLEmbedding.wordEmbedding(for: .traditionalChinese)
print(中文詞嵌入)

執行結果如下:
  1. 詞向量的空間維度是300維
  2. 蘋果(apple)與香蕉(banana)的距離是 0.82,與桌子(table)距離1.24 (0 < 距離 < 2.0;距離越小詞義越近)
  3. 與蘋果(apple)相近的單字包括藍莓(blueberry)、桃(peach)、梨(pear)…等,藍莓距離最小最近似
  4. 目前不支援中文



💡 註解
  1. 早期的自然語言處理將字詞轉換為數學的方法,大多採用數理邏輯與集合論,設法將語法及語義轉換成邏輯規則,衍生出形式語言(formal language)與專家系統。
  2. 後期在電腦的計算能力與儲存容量大幅成長之後,才開始嘗試將字詞轉換為高維向量,用統計學方法分析語義。
  3. 詞嵌入背後理論是基於語言學的語義分布假說(Distributional Hypothesis),認為嵌入在同樣上下文位置的詞,會有類似的語義,不論是哪種語言。著名的說法來自英國語言學家 J. R. Firth (1957): "you shall know a word by the company it keeps”。
  4. 不要嘗試去想像300維度空間是什麼樣子。我們所處的空間最多到 [x, y, z] 3維空間,愛因斯坦說還要加上時間 t,變成 [x, y, z, t] 4維空間,但實際上,超過3維就已經是抽象空間,只為方便在數學上用向量與矩陣加以操作,無法對應到實體空間。
  5. 詞嵌入可當作機器學習前段的準備工作,因此稱為預訓練(Pre-training),ChatGPT 所採用 GPT 3.5 語言模型的預訓練高達1,750億條參數(含詞嵌入),詞向量空間有1,536維度。
題外話:對WWDC 2023的期望

第5單元Swift人工智慧程式基礎的課程,到目前為止8課主體內容大致完成了,包括 AI 三大領域:視覺(4課)、聽覺(3課)、自然語言(1課),這樣的配置主要是因為AI視覺發展最成熟,所以比例最高,而自然語言是三者中最困難但 Apple 原廠框架相對較單薄,因此只能淺嘗輒止。

原先預計10課寫完要6個月左右,從2月初到現在,寫完8課(80%)用了整整4個月,剩下2課照原先構想,應該是用 Swift Playgrounds 的 App 模式各寫一個300-500行左右的專題,以綜合前面所學,這部份要等WWDC 2023(6/6-6/10)結束並消化完,約6月底再開始寫。

WWDC 對 Apple 生態圈的程式設計師來說特別重要,短期而言,可以先看到半年後正式發行的 iOS/macOS 有哪些新功能,提早準備;長期而言,可以看出 Apple 產品重心往什麼方向移動。

今年有兩個主題是筆者特別關心的,第一是AI 人工智慧,也就是目前第5單元課程所寫的主題,Apple 在 AI 方面的發展,早在 2011 年就領先業界率先發表 Siri 語音助理,將人工智慧內建到每一部iPhone手機,可惜12年過去,Apple 在 AI 領域從遙遙領先變成嚴重落後。

如今在 ChatGPT 橫空出世,Google, Microsoft, Amazon, Facebook... 等大廠競相投入 AI 的情況之下,Apple 沒有選擇,勢必得奮起直追,即使今年 WWDC 來不及端出大菜,至少也應公布未來 AI 發展策略。

第二個值得期待是傳說中的混合實境(MR),過去 Apple 在擴增實境(AR)已累積不少技術與資源,如果這次有突破性的產品發表,AR/MR 未來應用將很有潛力,很可能成為元宇宙的入口,筆者也在考慮下個單元寫AR/MR方面的課程。

若將 AR/MR 與 AI 結合一起,兩者堪稱絕配,因為 AR/MR 使用過程不會有鍵盤或滑鼠,一切操作必須透過手勢、肢體動作、臉部表情、語音、自然語言等,正是人工智慧可以充分發揮的舞台,甚至必須將 AI 整合到操作系統之中,目前也只有 Apple 具有這種軟硬體整合的能力,進而引領一個全新產業,這是筆者認為此次WWDC最值得期待的地方。
題外話:WWDC 2023的兩個重點

今年WWDC 2023中,最令人矚目的莫過於新產品Vision Pro,以及專屬的作業系統visionOS,這個新產品將虛擬實境(VR)與擴增實境(AR)進化到混合實境(MR),不但有沈浸式的感官體驗,也能戴著設備與周遭實體環境互動。

對筆者來說,更看重的是其作業系統 visionOS,這是 Apple 宣稱的「空間運算」(Spatial Computing)核心,「空間運算」一詞在2003年就有人提出,什麼意思呢?簡單地說,就是人機互動的全新變革,使用者不再透過滑鼠或鍵盤輸入指令,也不用觸控螢幕,而是在實體空間中直接用手勢、肢體或語音與電腦互動,就跟真人互動一樣,電腦所顯示的內容也都是3D立體的 — 這些過去只有在電影中能實現。

Vision Pro 不用任何控制器,完全採用眼球追蹤或手勢互動,這顯然已將人工智慧(特別是視覺辨識部分)整合到 visionOS 作業系統當中,更棒的是,大部分現有 iOS App 也能夠在 Vision Pro 中執行,要開發 visionOS 原生的 App並不難,用 SwiftUI 搭配 RealityKit 或 ARKit 就能呈現 3D 內容(如下圖)。


有趣的是,在 WWDC 2023 中,有些影片仍稱此作業系統為 xrOS,這是之前媒體爆料的名稱,可見直到最後才定名為 visionOS,這個名稱對應 Vision Pro 產品,同時也反映出以視覺互動為主,背後顯然用到大量的AI視覺辨識技術,也就是本單元課程用到的 Vision 框架。

另外,筆者注意到WWDC 2023 一部影片”Explore Natural Language multilingual models”,這與第8課(5-8d)有關,影片中提到目前 Apple 詞嵌入(Word Embedding)所用的模型稱為 ELMo,而今年新版則採用 Google 發表的 BERT,能同時支援多國語言,因此未來有希望支援中文。


有關 ELMo 與 BERT 技術原理,可參考台大電機系李宏毅教授的課程講解
第9課 與AI互動(5-9a)

如果將電腦比喻為人,有眼睛(鏡頭)、耳朵(麥克風)、嘴巴(喇叭),那麼,人工智慧就是賦予其視覺、聽覺、語言的能力,在本單元已學過的課程中,分別對應了 Apple 原廠的 Vision, Speech, NaturalLanguage 三個AI框架,再輔以 CoreML 整合第三方AI模型,以及 AVFoundation 框架提供底層的錄音、錄影、語音合成等基本功能。

在第7課「人機對話」我們做了一個語音對話的程式,讓人用語音和AI對話,那麼,能否用視覺跟AI互動呢?當然可以,例如用臉部表情或手勢來控制電腦,這也是擴增實境(AR)或Apple新發表 Vision Pro 的主要互動模式。

本單元最後兩課就來製作一個用AI視覺互動的App,體驗一下類似 Vision Pro 隔空操作的感覺。

5-9a 打開AI的眼睛(啟動攝影鏡頭)

本節先學如何在 SwiftUI 中開啟攝影鏡頭。

SwiftUI 目前並沒有直接開啟攝影鏡頭的物件,常見的做法有新舊兩種,舊方法是借助 UIKit 橋接到 SwiftUI,新方法則是用 RealityKit 在 WWDC 2023 新發表的 RealityView 物件。

不過這兩種做法都不完全符合本課需求,經過一番探索,找出了第三種方法 — 用底層的 AVFoundation 物件來操作攝影鏡頭,但不透過 UIKit,而是直接取用記憶體中的影像資料,傳給 AI 視覺辨識。這個方法稍具難度,但可幫助我們了解錄影的運作過程。

AVFoundation 包含許多影音的基礎物件,功能非常豐富,而且還持續推陳出新,可說是讓 Apple 設備充分發揮影音效果的寶庫。在此框架中,用來啟動攝影鏡頭拍照或錄影的關鍵物件稱為 AVCaptureSession(影音擷取任務),Capture 是捕獲、擷取的意思,在此所謂「影音擷取」就是錄影的意思。

首先,第一步我們先列出目前的攝影鏡頭,用到物件AVCaptureDevice(擷取設備)的子物件 DiscoverySession()。若設備沒有附帶鏡頭(如 Mac mini),程式還是能執行,只是沒有任何畫面。
// 在電子書(.playgroundbook)模式下執行
import AVFoundation

// 第一段:列出攝影鏡頭
let 鏡頭列表 = AVCaptureDevice.DiscoverySession(deviceTypes: [.builtInWideAngleCamera], mediaType: .video, position: .unspecified)
print(鏡頭列表.devices)

參數中的 .builtInWideAngleCamera 指內建的廣角鏡頭,這是基本款的攝影鏡頭,大多數Apple設備都會配備。在不同設備執行這幾行程式碼,所列出的結果如下:
  1. iPad (2018, 第6代) — 2個鏡頭
    [[後置相機][com.apple.avfoundation.avcapturedevice.built-in_video:0], [前置相機][com.apple.avfoundation.avcapturedevice.built-in_video:1]]

  2. Macbook Air (2020, M1) — 1個鏡頭
    [[FaceTime HD相機][EAB7A68F-EC2B-4487-AADF-D8A91C1CB782], [Heman 2020相機][30353333-0000-0000-0000-000000000001]]

  3. Mac mini (2018, Intel i3) — 空陣列,表示沒有攝影鏡頭
    []

上面 2. Macbook Air 除了自己的FaceTime HD相機之外,還透過通用控制(Universal Control)抓到手機Heman 2020相機。

確定有攝影鏡頭之後,第二步就是取得錄影授權,這是隱私權的一部分,需要使用者同意。程式使用 AVCaptureDevice 類型方法 requestAccess(),這個動作是非同步操作,Task { } 內的程式碼會分配到另一個執行緒,而主執行緒會繼續往下執行,所以後面要加 PlaygroundSupport 兩行程式碼,避免程式提前終止。
// 第二段:取得錄影授權
Task {
let 已授權 = await AVCaptureDevice.requestAccess(for: .video)
print("授權了嗎?\(已授權)")
}

// 本段需要等待非同步(另一個執行緒)傳回結果
import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true

第二段執行結果如下圖(iPad 2018):


取得錄影授權之後,第三步設定錄影任務並開啟鏡頭,這是本節的重點。這個步驟與第6課(5-6a)錄音使用 AVAudioSession 的過程類似,在此用的是 AVCaptureSession,同樣要設定輸入(AVCaptureDeviceInput)與輸出(AVCaptureVideoDataOutput),圖解如下:


在此,輸入設定為預設的鏡頭(iPad為後置相機),輸出則是存放到資料暫存區,以便後續傳給 AI 做視覺辨識。當透過 AVCaptureSession 錄影時,影(Video)、音(Audio)是分開的,若要處理聲音的部分(在影片中稱為「音軌」),需另外連接麥克風輸入以及 AVCaptureAudioDataOutput 輸出。

附帶一提,在程式中影片存檔與拍照也都同樣用 AVCaptureSession 來控制,差別只是輸出不同,影片存檔的輸出是AVCaptureMovieFileOutput,而拍照是 AVCapturePhotoOutput。

第三段程式碼如下,要注意以下語法順序,若將第三段整個挪到 Task { } 之外,執行時就會與第二段 requestAccess() 並行,但此時尚未得到授權,攝影鏡頭當然就無法啟動,因此必須在獲得授權之後,才能執行第三段。
// 注意變數的生命週期
let 錄影任務 = AVCaptureSession()

// 擴充第二段:取得錄影授權+開啟鏡頭
Task {
let 已授權 = await AVCaptureDevice.requestAccess(for: .video)
print("授權了嗎?\(已授權)")

// 第三段:開啟鏡頭
if 已授權 {
guard let 預設鏡頭 = AVCaptureDevice.default(for: .video) else { return }
guard let 錄影輸入 = try? AVCaptureDeviceInput(device: 預設鏡頭) else { return }
print(預設鏡頭, 錄影輸入)

let 錄影輸出 = AVCaptureVideoDataOutput()
if 錄影任務.canAddInput(錄影輸入) {
print("開始設置鏡頭參數")
錄影任務.beginConfiguration()
錄影任務.addInput(錄影輸入)
// 錄影輸出.setSampleBufferDelegate(影像接收者, queue: DispatchQueue(label: "影像取樣"))
錄影輸出.alwaysDiscardsLateVideoFrames = true
錄影任務.addOutput(錄影輸出)
錄影任務.commitConfiguration()
print("設置完畢")
錄影任務.startRunning()
}
}
}

// 本段需要等待非同步(另一個執行緒)傳回結果
import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true

上面第三段有一行暫時先註解起來,也就是 `錄影輸出.setSampleBufferDelegate()`,這行程式碼非常關鍵,牽涉到我們如何處理記憶體暫存區的影像資料,Sample 意思是樣本(擷取畫面),也就是每一幀畫面(每秒可能產生30幀畫面),Buffer 指記憶體暫存區,Delegate 指代理者,setSampleBufferDelegate() 就是設定要將影像暫存區交給誰來處理。

這裡的「誰」並不是人,而是某個物件實例,只要符合某種規範(protocol)的物件,在物件中包含一個 captureOutput() 方法,即可擔任這個角色。這也就是以下第四段程式碼的作用 — 將每一幀畫面轉換成適合AI辨識的 CGImage 格式:
// 第四段:影像接收者
import CoreImage
var 影像接收者 = 相機鏡頭() // 物件「相機鏡頭」宣告如下:

class 相機鏡頭: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate {
var 單幅影像: CGImage? = nil
func captureOutput(
_ output: AVCaptureOutput,
didOutput sampleBuffer: CMSampleBuffer,
from connection: AVCaptureConnection)
{
let 圖層 = CIContext()
if let 暫存區影像 = CMSampleBufferGetImageBuffer(sampleBuffer) {
let 轉換格式 = CIImage(cvPixelBuffer: 暫存區影像)
if let 影像 = 圖層.createCGImage(轉換格式, from: 轉換格式.extent) {
單幅影像 = 影像
print(Date(), 單幅影像?.width, 單幅影像?.height)
}
}
}
}

上面所提的規範有個很長名稱:AVCaptureVideoDataOutputSampleBufferDelegate,要符合這個規範,有兩個條件,一是必須用 class 定義,二是須包含一個名為 captureOutput() 物件方法,裡面3個參數也須符合規定(參考官方文件說明)。

為了符合規範,我們在課程中第一次用 class 來定義物件類型。class 的語法與 struct 幾乎一樣,都是用來定義物件類型,但本課程從第1單元至今全部使用 struct,特意避免用 class,為什麼呢?原因會在下一節討論。

在此,我們先看物件方法 captureOutput() 裡面的程式碼,過程大致是將記憶體暫存區的影像先轉換成 CIImage 格式,再透過圖層 CIContext() 轉換為 CGImage 格式,這個過程與第一課(5-1a)產出QR Code 十分類似。

captureOutput() 函式最後將 CGImage 存到物件屬性「單幅影像」中,以便後續取用,最後一行列印目前時間以及影像寬高,讓我們可以觀察影像擷取(也就是錄影)的詳細過程。

整合以上四段,完整程式碼如下:
// 5-9a 開啟攝影鏡頭
// Created by Heman, 2023/06/23
// 在電子書(.playgroundbook)模式下執行
import AVFoundation

// 第一段:列出攝影鏡頭
let 鏡頭列表 = AVCaptureDevice.DiscoverySession(deviceTypes: [.builtInWideAngleCamera], mediaType: .video, position: .unspecified)
print(鏡頭列表.devices)

// 第三段的全域變數
let 錄影任務 = AVCaptureSession()

// 擴充第二段:取得錄影授權+開啟鏡頭
Task {
let 已授權 = await AVCaptureDevice.requestAccess(for: .video)
print("授權了嗎?\(已授權)")

// 第三段:開啟鏡頭
if 已授權 {
guard let 預設鏡頭 = AVCaptureDevice.default(for: .video) else { return }
guard let 錄影輸入 = try? AVCaptureDeviceInput(device: 預設鏡頭) else { return }
print(預設鏡頭, 錄影輸入)

let 錄影輸出 = AVCaptureVideoDataOutput()
if 錄影任務.canAddInput(錄影輸入) {
print("開始設置鏡頭參數")
錄影任務.beginConfiguration()
錄影任務.addInput(錄影輸入)
錄影輸出.setSampleBufferDelegate(影像接收者, queue: DispatchQueue(label: "影像取樣"))
錄影輸出.alwaysDiscardsLateVideoFrames = true
錄影任務.addOutput(錄影輸出)
錄影任務.commitConfiguration()
print("設置完畢")
錄影任務.startRunning()
}
}
}

// 第四段:影像接收者
import CoreImage
var 影像接收者 = 相機鏡頭() // 物件「相機鏡頭」宣告如下:

class 相機鏡頭: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate {
var 單幅影像: CGImage? = nil
func captureOutput(
_ output: AVCaptureOutput,
didOutput sampleBuffer: CMSampleBuffer,
from connection: AVCaptureConnection)
{
let 圖層 = CIContext()
if let 暫存區影像 = CMSampleBufferGetImageBuffer(sampleBuffer) {
let 轉換格式 = CIImage(cvPixelBuffer: 暫存區影像)
if let 影像 = 圖層.createCGImage(轉換格式, from: 轉換格式.extent) {
單幅影像 = 影像
print(Date(), 單幅影像?.width, 單幅影像?.height)
}
}
}
}

import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true

在 Swift Playgrounds 電子書模式(.playgroundbook)下執行,從主控台可以看到結果如下圖,在 Macbook Air (M1, 2020)上,每秒約取得6幀 1280x720 影像。由於第三段設定「錄影輸出.alwaysDiscardsLateVideoFrames = true」,來不及處理的影像會捨棄掉,以避免暫存區塞爆。


💡 註解
  1. 為什麼電腦沒有鼻子(嗅覺)呢?如果電腦有嗅覺,對人類有何好處?若要讓電腦有嗅覺,需要什麼條件?
  2. 筆者預計等年底 Swift Playgrounds 4.4 新版發表後,明(2024)年利用 RealityKit 來撰寫下個單元(第6單元),主題暫定為:擴增實境與3D設計。
  3. 與第6課(5-6a)提過的觀念類似,攝影鏡頭實際上也是由作業系統控制與管理,AVCaptureSession 只是擔任程式與作業系統之間的協調者角色,算是間接控制者。
語法說明:class 與 struct 異同

在前面課程(1-8a, 3-10a)曾提過,Swift 是個「物件導向」 — 也就是以物件為核心的程式語言,Swift 幾乎所有資料類型都是物件,這也是現代程式語言的特徵之一。

在 Swift 中定義一個物件類型主要有兩種方法:struct 與 class,兩者功能非常接近,語法也幾乎一樣。若直接以程式碼來比較,假設我們要寫個朋友通訊錄,可以分別用 struct 與 class 定義兩種「朋友」物件類型如下:
// class v.s. struct
// Tested by Heman, 2023/07/04
// 第一段
struct 新朋友 {
var 姓名: String
var 手機號碼: String
}

class 舊朋友 {
var 姓名: String
var 手機號碼: String
init(name: String, phone: String) {
姓名 = name
手機號碼 = phone
}
}

let 小明 = 新朋友(姓名: "王小明", 手機號碼: "0919123456")
let 阿呆 = 舊朋友(name: "郝呆", phone: "0921543876")
print("1. \(小明.姓名) \(小明.手機號碼)")
print("2. \(阿呆.姓名) \(阿呆.手機號碼)")

以上用 struct 與 class 定義「新朋友」與「舊朋友」資料類型,屬性同樣都有姓名與手機號碼。

用 class 定義物件時,若屬性沒有給預設值,就一定要寫初始化函式 init(),否則會出現錯誤訊息。用 struct 方便一些,但並非不需要初始化函式,而是系統會自動幫我們產生預設的初始化函式。

初始化函式主要目的就是給每個屬性初始值,有了初始化函式,才能產出物件實例,如後面兩行 let 指定句,產出小明與阿呆兩個物件實例。最後再用 print() 列印物件屬性,用法完全相同,第一段執行結果如下:
1. 王小明 0919123456
2. 郝呆 0921543876

除了初始化函式有點小差異之外,class 與 struct 還有三個主要不同,第一是物件實例產生副本(如複製或當作參數)時,class 物件是 “Reference type”,而 struct 則是 “Value type”,下面會進一步說明。

第二是 class 物件類型之間,可以有繼承關係,下層(子)類型可以繼承上層(父)類型的所有屬性與方法,就像生物分類階層一樣(如人種具備靈長目的所有特徵,靈長目具備哺乳綱的所有特徵)。而 struct 物件類型之間,沒有繼承關係,改用規範(protocol)定義上層的共同屬性。

不要小看以上兩個不同點,這牽涉到物件的根本性質,造成 class 與 struct 在程式設計上完全不同的思考方式,就像兩個不同文化的外國人。

因此,雖然 class 與 struct 能夠混用(也經常混用),但對初學者而言,應儘量以其中之一為主、另一種為輔的做法,這也是第三個不同的原因,SwiftUI 物件大多採用 struct 定義,而過去的 UIKit 與 AppKit 則以 class 為主。

以上三點 class 與 struct 物件特性的差異歸納如下表:
# 性質 struct class
1 副本特性 Value type Reference type
2 上下層級關係 規範(Protocol) 繼承(Inheritance)
3 相關 UI 框架 SwiftUI AppKit, UIKit

回過頭來,我們用程式碼來說明 Reference type 與 Value type 的差別。

假設我們給小明與阿呆這兩位朋友複製一個分身,分別叫做大明與阿瓜,一開始姓名、手機號碼兩個屬性都與本尊一樣,後來分身想改名字,大明改名”王大明”,阿瓜改名”郝美麗”:
// 第二段
var 大明 = 小明
var 阿瓜 = 阿呆

大明.姓名 = "王大明"
阿瓜.姓名 = "郝美麗"

print("3. \(小明.姓名) \(大明.姓名)")
print("4. \(阿呆.姓名) \(阿瓜.姓名)")

最後列印出小明、大明、阿呆、阿瓜四個人的姓名,會發現一件神奇的事情:
3. 王小明 王大明
4. 郝美麗 郝美麗

列印出來的結果,王小明、王大明姓名正確,但是阿呆、阿瓜竟然都變成郝美麗,為什麼會這樣?

原因是 struct 產出的物件實例,變數名稱所代表的是物件內容(屬性值),在指定分身時,會複製一份內容到新變數,之後兩個變數再也沒有關係,各自擁有自己的屬性值。

但 class 不一樣,用 class 產出的物件實例,變數名稱代表的是物件實例的指標(稱為 pointer 或 reference),當指定分身時,複製的是指標,兩個指標實際上指向同一個物件實例,因此分身改了屬性值,本尊同樣會受影響。

上面的例子中,分身阿瓜改名為”郝美麗”,結果本尊阿呆同樣也變成”郝美麗”。注意在第一段,阿呆是用 let 定義的,為什麼還能變更屬性值呢?原因就是 Reference type 的變數代表的是指標,而不是內容,因此 let 只限制指標不能變,指標所指的內容是可以改的。

函式在處理參數時,Reference type 與 Value type 參數的情況也完全不同。對於 Reference type 參數,若在函式裡面改了屬性值,參數的本尊也會跟著改,因為兩者指標指到同一個地方;若是 Value type 參數,則只會傳入唯讀的物件內容(屬性值),參數本身不允許更改。

接下來,我們增加一個物件方法,讓物件自己可以更名:
// 第三段
extension 新朋友 {
mutating func 改名(newName: String) {
self.姓名 = newName
}
}

extension 舊朋友 {
func 改名(newName: String) {
self.姓名 = newName
}
}

大明.改名(newName: "王太明")
阿瓜.改名(newName: "郝完美")
print("5. \(小明.姓名) \(大明.姓名)")
print("6. \(阿呆.姓名) \(阿瓜.姓名)")

注意到 struct 物件方法,如果要更改屬性值,必須明確寫出 mutating (變異、變化),沒有寫 mutating 的物件方法,即使是自己的屬性也不能隨意變更。class 則比較自由,任何地方可以改屬性內容。
5. 王小明 王太明
6. 郝完美 郝完美


以上就是 Reference type 與 Value type 的差異,兩種變數的行為完全不同,若習慣用 struct 的人,會將每個變數當作獨立的物件實例,而常用 class 的人,則習慣用指標來思考。

Reference type 與 Value type 那個比較好呢?其實各有優缺點,Reference type 對複雜的物件特別有優勢,因為不必複製大量重複的內容,而且透過指標指向同一個物件,可以確保 “Single source of truth”。

而 Value type 最大優勢是安全,初級程式設計師可能體會不到,但若軟體複雜到一定程度,Reference type 容易產生副作用(”side effect”),例如變數被另一個分身或在某個函式更改了內容卻沒發覺。Value type 對物件屬性的更改有較嚴格的語法限制。

從發展歷史來看,早期的物件導向語言只有一種物件,就是 class,故可稱之為傳統物件類型,傳統物件有三個主要特性,包括 1. 可繼承(Inheritance) 2. 可封裝(Encapsulation) 3. 可變形(Polymorphism),三者各自影響語法與使用,都是針對 class 而言。

傳統 class 物件用到現在已超過50年,雖然還是很好用,但也發現不少缺點,其中之一就是繼承關係可能非常複雜,例如 UIKit 龐大的「族譜」中(如下圖),UITextView 繼承關係為 NSObject → UIResponder → UIView → UIScrollView → UITextView,如果想要充分了解 UITextView,就必須將上面所繼承的物件也都理解過,對初學者是個沈重的負擔。


為了簡化繼承的複雜性,Swift 加入規範(Protocol)的概念,將共同的屬性或方法放到規範中,規範本身可以有上下階層關係。這樣一來物件與規範之間的關係變得非常單純,而且不只 struct 可用,class 也可以用規範,例如上一節(5-9a)的「class 攝影鏡頭」,繼承 NSObject,且符合 AVCaptureVideoDataOutputSampleBufferDelegate 規範。

而 struct 完全不用繼承,純粹靠規範來定義上層的資料模型,同樣可以應付複雜的問題。事實上,Swift 號稱自己是規範導向語言(Protocol-oriented language),而不說是物件導向,就可知道 protocol 在 Swift 語言所佔的重要性。

💡 註解
  1. Swift 還有第3種定義物件的方法,就是 enum (列舉),也就是說,enum 除了定義 case 值,還能定義函式(物件方法),enum 也是 Value type。不過,enum 定義的物件與 class, struct 用途明顯不同,故在此不做比較。
  2. 筆者建議初學者定義物件時,儘量用 struct,而不用 class。Swift 官方文件也提到 “In practice, this means most of the custom types you define will be structures and enumerations.”,請參考 Swift Structures and Classes
  3. 事實上,Swift 還有第4種物件,叫做 actor (演員),用來解決多執行緒並行計算時會遇到的資料衝突問題(data race),屬於進階語法,在此不提。
  4. 規範(protocol)並不是物件。規範可以想成是 class 或 struct 的上一層類別(就像生物分類時,「種」的上層「屬」或「科」,也就是範圍較大的類別),但規範本身並不是物件類型,無法直接產出物件實例。
  5. 圖片來源:UIKit Class Hierarchy Chart
CUNNING
看到這張表真的會昏倒
雪白西丘斯 wrote:
人工智慧程式並非如此,同樣的程式與輸入條件,在不同時間可能產生不同的結果,而且連寫程式的作者,也無法預期執行的準確結果。為什麼會這樣?下一節我們會以最近非常熱門的 ChatGPT 人工智慧程式來當例子,實際觀察一下這個現象。

但為什麼人工智慧程式不像一般程式那樣,完全根據輸入產生唯一的結果,這樣既容易預期,也好判斷對錯呢?簡單的說,因為人工智慧程式所針對的問題,大多沒有明確答案,就像下棋,在任一時間,問下一步下在哪個位置贏面最大?這就產生很多選擇,甚至可能沒有一個正確答案。

為了達到圖靈與麥卡錫所期望,讓電腦也能夠理解人類的智慧,目前人工智慧程式大多採用數學上的統計模型來「趨近」答案,也就是說,AI 每一次回答,其實都帶有隨機性,即使相對較明確的答案可能是代表99.9%機率(有0.1%機率會出現其他結果),不像一般程式那樣,100%一定都是同樣結果。

隨機性通常是依需求刻意加入(例如llm的temperature參數)
或是運算效能上的考量(像cudnn通常不會開deterministic模式)
並非ai的本性

dl模型講白了就是一堆數值的加權
以最簡單的 y = ax + b來說 (a, b是模型參數, x是input, y是output)
同個模型給相同輸入x的話, 其實y也會固定的
kouyoumin
以下只是個人看法啦, 隨機性除了可以讓輸出更豐富(想像chatgpt若總是答一樣的話是不是很無趣?), 某些情況也可以用來讓本來不顯著的資訊表現出來(類似dither讓lsb以下的訊號表現出來)
雪白西丘斯
謝謝您的答覆,這是一個很好的問題,我來問問ChatGPT
文章分享
評分
評分
複製連結

今日熱門文章 網友點擊推薦!