Flashcard ứng dụng thuật toán SuperMemo (Phần 1 + 2)
Bài đăng này đã không được cập nhật trong 3 năm
1. Giới thiệu
1.1. Flashcard
Flashcard hoặc Flash Card là loại thẻ mang thông tin (từ, số hoặc cả hai), được sử dụng cho việc học bài trên lớp hoặc trong nghiên cứu cá nhân. người dùng sẽ viết một câu hỏi ở mặt trước thẻ và một câu trả lời ở trang sau. Người ta thường dùng flashcard học từ vựng tiếng Anh rất hiệu quả. Ngoài ra có thể dùng flashcard để học ngày tháng năm lịch sử, công thức hoặc bất kỳ vấn đề gì có thể được học thông qua định dạng một câu hỏi và câu trả lời. Fl ashcard được sử dụng rộng rãi như một cách rèn luyện để hỗ trợ ghi nhớ bằng cách lặp đi lặp lại cách nhau.
Flashcard là một công cụ ôn tập rất hiệu quả. Theo khoa học nghiên cứu, với một lượng kiến thức cần nhớ, thì sau 1 ngày tiếp thu, người học chỉ còn nhớ 35.7% lượng kiến thức và sau 1 tháng, lượng kiến thức chỉ còn khoảng 21% trong não bộ. Vì thế, việc ôn tập lại kiến thức đóng vai trò rất quan trọng trong quá trình ghi nhớ.
Không dừng lại ở tính hiệu quả cao, flashcard còn là một phương pháp học năng động. Với thiết kế nhỏ gọn, người học có thể đem flashcard theo bên mình và sử dụng mọi lúc mọi nơi.
(theo Wiki)
Ngày nay với sự phát triển của smart phone, có rất nhiều chương trình flashcard, chủ yếu dùng để học ngoại ngữ. Phần lớn các chương trình để nâng cao tính hiệu quả của việc học đều dùng một thuật toán gọi là Spaced Repetition.
1.2. Spaced Repetition
Spaced Repetition (SR) là một kỹ năng học tập dựa trên việc tính toán các khoảng thời gian giữa các lần ôn lại bài học tuỳ theo độ khó của bài học và trí nhớ của người học.
SR thích hợp trong nhiều hoàn cảnh, đặc biệt trong trường hợp người học cần phải ghi nhớ một lượng lớn nội dung, ví dụ như học từ mới ngoại ngữ.
Hình vẽ dưới đây mô tả quá trình học flashcard dựa trên SR: các thẻ trả lời đúng được đưa lên các hộp tiếp theo (hộp có số thứ tự càng lớn thì càng ít lặp lại thường xuyên) và các thẻ trả lời sai bị trả về hộp đầu tiên (lặp lại thường xuyên hơn)
1.3. Thuật toán SuperMemo
SuperMemo (Super Memory - SM) là một phương pháp học tập và phần mềm được phát triển bởi SuperMemo World và SuperMemo R&D, tác giả là Piotr Woźniak người Phần Lan từ năm 1985 tới nay. Thuật toán này dựa trên nghiên cứu về trí nhớ dài hạn và ứng dụng phương pháp SR được đề xuất bởi một số nhà tâm lý học vào đầu những năm 1930.
Các thuật toán SM gồm:
- SM-0: Thuật toán gốc (không dựa trên máy tính)
- SM-2: Dựa trên máy tính (1987), các phiên bản tiếp theo tối ưu hoá ưu điểm của thuật toán này.
- SM-15: hiện tại đang được SuperMemo sử dụng
Trong đó, SM-2 được sử dụng rộng rãi trong nhiều ứng dụng miễn phí phổ biến như Anki, Mnemosyne, Emacs Org-mode's Org-drill…
1.4. Thuật toán SM-2
Công thức:
I(1):=1
I(2):=6
for n>2 I(n):=I(n-1)*EF
Trong đó:
- I(n) (interval): trả về khoảng thời gian đối tượng sẽ lặp lại (tính bằng ngày) sau n lần thử
- EF (E-Factor): hệ số độ dễ, phản ánh độ dễ hay khó của đối tượng trong việc ghi nhớ.
EF: biến thiên từ 1.1 (khó nhất) và 2.5 (dễ nhất), mặc định khi một đối tượng được lưu vào database sẽ có Ef = 2.5. Trong quá trình học, giá trị này sẽ tăng hoặc giảm tuỳ thuộc vào sự ghi nhớ của người học.
Giá trị EF mới được tính toán dựa trên chất lượng câu trả lời của người học, lựa chọn 1 trong 6 tuỳ chọn:
- 5 - Hoàn hảo
- 4 - Trả lời chính xác nhưng còn phải đắn đo
- 3 - Trả lời chính xác nhưng gặp nhiều khó khăn
- 2 - Trả lời không chính xác, đáp án đúng dễ dàng nhớ ra
- 1 - Trả lời sai, nhớ được đáp án
- 0 - Hoàn toàn không nhớ
Công thức:
EF’:=EF+(0.1-(5-q)*(0.08+(5-q)*0.02))
Trong đó:
- EF’ - giá trị mới của E-Factor
- EF - giá trị cũ của E-Factor
- q - giá trị của câu trả lời (0~5)
Khi EF < 1.3, gán EF = 1.3 (Đối tượng có EF < 1.3 sẽ lặp lại thường xuyên gây khó chịu)
Khi giá trị câu trả lời nhỏ hơn 3, ta tiến hành lập lại đối tượng từ đầu mà không thay đổi EF (VD: I(1), I(2) coi như đối tượng được học mới)
Sau mỗi lần học của 1 ngày, lặp lại tất cả các đối tượng có giá trị trả lời (q) nhỏ hơn 4. Tiếp tục lặp lại cho đến khi toàn bộ các đối tượng có giá trị trả lời ít nhất là 4.
Trong khuôn khổ bài viết này, tôi sẽ hướng dẫn các bạn tạo 1 ứng dụng iOS Flashcard áp dụng thuật toán SM.
2. Xây dựng ứng dụng MGFlashcardDemo
2.1. Tạo ứng dụng
Mở Xcode, tạo mới 1 ứng dụng iOS theo template Tabbed Application, ngôn ngữ Swift như hình dưới:
2.2. Xây dựng class áp dụng thuật toán SM-2
2.2.1. SchedulingAlgorithm
SchedulingAlgorithm là 1 abstract class, base class của các class ứng dụng thuật toán SM.
class SchedulingAlgorithm: NSObject {
var eFactor: Double = 0
private var _qualityResponse: Int = 0
var qualityResponse: Int {
get {
return _qualityResponse
}
set {
_qualityResponse = newValue
if _qualityResponse < 3 {
eFactor = defaultEFactor
}
}
}
var defaultEFactor: Double = 2.5
func getNextInterval(n: Int) -> Int {
return 0 // Abstract class
}
func getNewEFactor() -> Double {
return 0 // Abstract class
}
}
2.2.2. SM2
class SM2: SchedulingAlgorithm {
override init() {
super.init()
eFactor = 2.5
qualityResponse = 0
}
init(eFactor: Double, qualityResponse: Int) {
super.init()
self.eFactor = eFactor
self.qualityResponse = qualityResponse
}
override func getNextInterval(n: Int) -> Int {
if (n==1) {
return 1
}
else if (n==2) {
return 6
}
else if (n>2) {
return Int(Double(n-1)*eFactor)
}
else {
return 0
}
}
override func getNewEFactor() -> Double {
var newEFactor: Double = eFactor + (0.1-Double(5-qualityResponse)*(0.08+Double(5-qualityResponse)*0.02))
if (newEFactor < 1.3) {
newEFactor = 1.3
}
return newEFactor
}
}
2.2.3. TestSM2
class TestSM2: NSObject {
func testIntervals() {
var grades = [0, 3, 4, 5, 5, 1, 4, 5, 5, 5, 4, 3, 4, 5]
var efs = [ Double(2.5) ]
var intervals = [1]
for (index, grade) in enumerate(grades) {
let sm2 = SM2(eFactor: efs[index], qualityResponse: grades[index])
let newEF = sm2.getNewEFactor()
let newInterval = sm2.getNextInterval(index)
efs.append(newEF)
intervals.append(newInterval)
}
for interval in intervals {
println(interval)
}
for ef in efs {
println(ef)
}
}
}
Hàm testIntervals
trả về thời gian lặp lại tiếp theo của đối tượng (tính theo ngày) sau khi chọn câu trả lời, trong đó:
- grades: mô phỏng sự lựa chọn câu trả lời của người học
- efs: lưu lại các sự thay đổi eFactor của đối tượng sau mỗi lần người học chọn câu trả lời
- intervals: lưu lại các thời gian lặp lại
Chạy hàm test trên (có thể đặt trong viewDidLoad của ViewController) ta được kết quả như sau:
override func viewDidLoad() {
super.viewDidLoad()
let testSM2 = TestSM2()
testSM2.testIntervals()
}
Interval
1
0
1
6
3
4
10
9
11
14
17
20
22
23
25
eFactor
2.5
1.7
1.56
1.56
1.66
1.76
1.96
1.96
2.06
2.16
2.26
2.26
2.12
2.12
2.22
2.3. Tạo database CoreData
Card Entity:
#import <Foundation/Foundation.h>
#import <CoreData/CoreData.h>
@interface Card : NSManagedObject
@property (nonatomic, retain) NSString * back;
@property (nonatomic, retain) NSDate * creationTime;
@property (nonatomic, retain) NSNumber * due;
@property (nonatomic, retain) NSNumber * factor;
@property (nonatomic, retain) NSString * front;
@property (nonatomic, retain) NSString * id;
@property (nonatomic, retain) NSNumber * interval;
@property (nonatomic, retain) NSNumber * lapses;
@property (nonatomic, retain) NSDate * modificationTime;
@property (nonatomic, retain) NSNumber * queue;
@property (nonatomic, retain) NSNumber * reviews;
@property (nonatomic, retain) NSNumber * type;
@end
2.4. MagicalRecord
Sử dụng thư viện MagicalRecord để giúp đơn giản hoá việc tương tác với CoreData.
Ta sẽ dùng CocoaPod để thêm thư viện MagicalRecord.
pod 'MagicalRecord', '~> 2.3’
Cách cài đặt và sử dụng CocoaPod bạn có thể tham khảo ở trang https://cocoapods.org
2.5. Services
Ta sẽ viết các service class để tương tác với database
2.5.1. CardDto
import UIKit
enum CardType: Int {
case New = 0
case Learning = 1
case Due = 2
}
enum CardQueue: Int {
case ScheduleBuried = -3
case UserBuried = -2
case Suspended = -1
case New = 0
case Learning = 1
case Due = 2
}
class CardDto: NSObject {
var id: String = ""
var creationTime: NSDate = NSDate()
var modificationTime: NSDate = NSDate()
var type = CardType.New
var queue = CardQueue.New
var due = 0
var interval = 0
var factor: Double = 2.5
var reviews = 0
var lapses = 0
var front = ""
var back = ""
var qualityResponse = -1
}
CardDto
sẽ map dữ liệu với Card Entitiy.
2.5.2. Mapper
class Mapper: NSObject {
class func mapCardDto(cardDto: CardDto, toCard card: Card) {
card.id = cardDto.id
card.creationTime = cardDto.creationTime
card.modificationTime = cardDto.modificationTime
card.type = cardDto.type.rawValue
card.queue = cardDto.queue.rawValue
card.due = cardDto.due
card.interval = cardDto.interval
card.factor = cardDto.factor
card.reviews = cardDto.reviews
card.lapses = cardDto.lapses
card.front = cardDto.front
card.back = cardDto.back
}
class func mapCard(card: Card, toCardDto cardDto: CardDto) {
cardDto.id = card.id
cardDto.creationTime = card.creationTime
cardDto.modificationTime = card.modificationTime
cardDto.type = CardType(rawValue: card.type.integerValue)!
cardDto.queue = CardQueue(rawValue: card.queue.integerValue)!
cardDto.due = card.due.integerValue
cardDto.interval = card.interval.integerValue
cardDto.factor = card.factor.doubleValue
cardDto.reviews = card.reviews.integerValue
cardDto.lapses = card.lapses.integerValue
cardDto.front = card.front
cardDto.back = card.back
if card.deck != nil {
cardDto.deck = deckDtoFromDeck(card.deck)
cardDto.deckId = cardDto.deck.id
}
}
class func cardDtoFromCard(card: Card) -> CardDto {
let cardDto = CardDto()
mapCard(card, toCardDto: cardDto)
return cardDto
}
}
2.5.3. CardService
import UIKit
class CardService: NSObject {
func addCard(cardDto: CardDto) {
MagicalRecord.saveWithBlockAndWait { (context) -> Void in
let card = Card.MR_createEntityInContext(context)
Mapper.mapCardDto(cardDto, toCard: card)
}
}
func updateCard(cardDto: CardDto) {
MagicalRecord.saveWithBlockAndWait { (context) -> Void in
var predicate = NSPredicate(format: "id = '\(cardDto.id)'")
let card = Card.MR_findFirstWithPredicate(predicate, inContext: context)
if card != nil {
card.modificationTime = NSDate()
Mapper.mapCardDto(cardDto, toCard: card)
}
}
}
func deleteCardByCardId(cardId: String) {
MagicalRecord.saveWithBlockAndWait { (context) -> Void in
var predicate = NSPredicate(format: "id = '\(cardId)'")
Card.MR_deleteAllMatchingPredicate(predicate, inContext: context)
}
}
func getAllCards() -> [CardDto] {
var cardDtos = [CardDto]()
let cards = Card.MR_findAll()
for card in cards {
cardDtos.append(Mapper.cardDtoFromCard(card as! Card))
}
return cardDtos
}
}
2.5.4. Deck
import UIKit
class Deck: NSObject {
private var cards: [CardDto]!
private var cardService = CardService()
var newCardCount: Int {
get {
var count = 0
for card in cards {
if card.type == CardType.New {
count++
}
}
return count
}
}
var reviewCount: Int {
get {
var count = 0
for card in cards {
if card.type == CardType.Learning && card.qualityResponse < 3 {
count++
}
}
return count
}
}
func getCards() {
let cards = cardService.getAllCards()
self.cards = sorted(cards){ $0.interval < $1.interval }
}
func getNextCard() -> CardDto? {
var nextCard: CardDto!
for card in cards {
if card.type == CardType.New {
if nextCard == nil {
nextCard = card
}
else if card.interval < nextCard.interval {
nextCard = card
}
}
}
if nextCard != nil {
return nextCard
}
for card in cards {
if card.type == CardType.Learning && card.qualityResponse < 3 {
if nextCard == nil {
nextCard = card
}
else if card.interval < nextCard.interval {
nextCard = card
}
}
}
return nextCard
}
func setCardQualityResponse(card: CardDto, qr: Int) {
card.qualityResponse = qr
card.reviews++
card.type = CardType.Learning
let sm = SM2(eFactor: card.factor, qualityResponse: qr)
card.interval = sm.getNextInterval(card.reviews)
card.factor = sm.getNewEFactor()
cardService.updateCard(card)
}
}
Trong đó
newCardCount
: trả về số lượng card mớireviewCount
: trả về số luộng card cần reviewgetCards
: lấy toàn bộ card trong database và sort theo interval của card. Trong thực tế thì chỉ nên lọc 20 card mới và các card cần review theo ngàygetNextCard
: trả về card tiếp theo, việc trả card về ưu tiên card mới, sau đó ưu tiên card có interval thấp. Khi không có card trả về nghĩa là bạn đã hoàn thành việc học cho ngày hôm naysetCardQualityResponse
: căn cứ vào câu trả lời của người dùng interval và eFactor của card được cập nhật thông qua thuật toán SM-2
2.6. Giao diện người dùng
2.6.1. Storyboard
Tại Storyboard, ta tạo các view controller như sau, gồm có CardListViewController
, CardViewController
kế thừa UITableViewController
và FlashcardViewController
kế thừa UIViewController
2.6.2. CardViewController
Sử dụng để thêm, sửa thông tin Card
protocol CardViewControllerDelegate: class {
func cardViewControllerDidSave(sender: CardViewController)
}
class CardViewController: UITableViewController {
@IBOutlet weak var frontTextField: UITextField!
@IBOutlet weak var backTextField: UITextField!
weak var delegate: CardViewControllerDelegate?
var card: CardDto?
let cardService = CardService()
override func viewDidLoad() {
super.viewDidLoad()
if let card = card {
self.title = "Edit Card"
frontTextField.text = card.front
backTextField.text = card.back
}
frontTextField.becomeFirstResponder()
}
// MARK: - Events
@IBAction func onCancelButtonClicked(sender: AnyObject) {
self.dismissViewControllerAnimated(true, completion: nil)
}
@IBAction func onSaveButtonClicked(sender: AnyObject) {
if card == nil {
card = CardDto()
}
if let card = self.card {
card.front = frontTextField.text
card.back = backTextField.text
if card.id.isEmpty { // add card
card.id = NSUUID().UUIDString
cardService.addCard(card)
}
else {
cardService.updateCard(card)
}
}
delegate?.cardViewControllerDidSave(self)
self.dismissViewControllerAnimated(true, completion: nil)
}
// MARK: - Table view data source
override func numberOfSectionsInTableView(tableView: UITableView) -> Int {
return 1
}
override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return 2
}
}
2.6.3. CardListViewController
Sử dụng để quản lý card với các chức năng liệt kê, thêm, sửa xoá Card.
class CardListViewController: UITableViewController, CardViewControllerDelegate {
var cards = [CardDto]()
var cardService = CardService()
override func viewDidLoad() {
super.viewDidLoad()
loadCards()
let testSM2 = TestSM2()
testSM2.testIntervals()
}
private func loadCards() {
cards = cardService.getAllCards()
tableView.reloadData()
}
// MARK: - Events
@IBAction func onAddButtonClicked(sender: AnyObject) {
self.performSegueWithIdentifier("addCard", sender: nil)
}
@IBAction func onCreateDeckButtonClicked(sender: AnyObject) {
}
// MARK: - Table view data source
override func numberOfSectionsInTableView(tableView: UITableView) -> Int {
return 1
}
override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return cards.count
}
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("CardCell", forIndexPath: indexPath) as! UITableViewCell
let card = cards[indexPath.row]
cell.textLabel?.text = card.front
cell.detailTextLabel?.text = card.back
return cell
}
override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
let card = cards[indexPath.row]
self.performSegueWithIdentifier("addCard", sender: card)
}
// Override to support editing the table view.
override func tableView(tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: NSIndexPath) {
if editingStyle == .Delete {
// Delete the row from the data source
let card = cards[indexPath.row]
cardService.deleteCardByCardId(card.id)
cards.removeAtIndex(indexPath.row)
tableView.deleteRowsAtIndexPaths([indexPath], withRowAnimation: .Fade)
}
}
// MARK: - Navigation
// In a storyboard-based application, you will often want to do a little preparation before navigation
override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
if segue.identifier == "addCard" {
let controller = (segue.destinationViewController as! UINavigationController).topViewController as! CardViewController
controller.card = sender as? CardDto
controller.delegate = self
}
}
// MARK: - CardViewControllerDelegate
func cardViewControllerDidSave(sender: CardViewController) {
loadCards()
}
}
Chạy thử chương trình và thêm 1 vài Card:
2.6.4. FlashcardViewController
FlashcardViewController
là màn hình chính của ứng dụng, cho phép người dùng học card mới và học lại (review) card đã học.
import UIKit
class FlashcardViewController: UIViewController {
@IBOutlet weak var frontLabel: UILabel!
@IBOutlet weak var backLabel: UILabel!
@IBOutlet weak var cardCountLabel: UILabel!
@IBOutlet weak var reviewLabel: UILabel!
@IBOutlet weak var wrongButton: UIButton!
@IBOutlet weak var unsureButton: UIButton!
@IBOutlet weak var correctButton: UIButton!
var card: CardDto?
var showAnswer = false
var deck: Deck!
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
}
override func viewWillAppear(animated: Bool) {
super.viewWillAppear(animated)
deck = Deck()
deck.getCards()
card = deck.getNextCard()
showAnswer = false
configView()
}
private func configView() {
if showAnswer && card != nil {
frontLabel.text = card!.front
backLabel.text = card!.back
wrongButton.hidden = false
correctButton.hidden = false
unsureButton.setTitle("Unsure", forState: UIControlState.Normal)
}
else {
frontLabel.text = card?.front
backLabel.text = nil
wrongButton.hidden = true
correctButton.hidden = true
unsureButton.setTitle("Show Answer", forState: UIControlState.Normal)
}
unsureButton.enabled = card != nil
cardCountLabel.text = "New Card: \(deck.newCardCount)"
reviewLabel.text = "Review: \(deck.reviewCount)"
if card == nil {
let alertView = UIAlertView(title: "There are no cards to learn", message: nil, delegate: nil, cancelButtonTitle: "OK")
alertView.show()
}
}
private func setCardQualityResponse(qr: Int) {
if card != nil {
deck.setCardQualityResponse(card!, qr: qr)
card = deck.getNextCard()
showAnswer = false
configView()
}
}
@IBAction func onWrongButtonClicked(sender: AnyObject) {
setCardQualityResponse(0)
}
@IBAction func onUnsureButtonClicked(sender: AnyObject) {
showAnswer = !showAnswer
if showAnswer {
configView()
}
else {
setCardQualityResponse(2)
}
}
@IBAction func onCorrectButtonClicked(sender: AnyObject) {
setCardQualityResponse(5)
}
}
Chạy chương trình và test màn hình flashcard.
Hiển thị từ:
Hiển thị đáp án và tùy chọn trả lời:
3. Kết luận
Như vậy các bạn đã hoàn thành phần mểm đơn giản hỗ trợ việc học ngoại ngữ. Các bạn có thể mở rộng và thay đổi chương trình để hỗ trợ tạo deck cũng như giới hạn số lượng từ mới học mỗi ngày.
Các bạn có thể theo dõi và download project tại: Github
Cảm ơn đã theo dõi.
All rights reserved