+3

Flashcard ứng dụng thuật toán SuperMemo (Phần 1 + 2)

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ớ.

Flashcard

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)

Spaced Repetition

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:

New Project

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:

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ới
  • reviewCount: trả về số luộng card cần review
  • getCards: 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ày
  • getNextCard: 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 nay
  • setCardQualityResponse: 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 UITableViewControllerFlashcardViewController kế thừa UIViewController

Story Board

2.6.2. CardViewController

Sử dụng để thêm, sửa thông tin Card

CardViewController

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.

CardListViewController

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:

Demo 1

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.

FlashcardViewController

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ừ:

Demo 2

Hiển thị đáp án và tùy chọn trả lời:

Demo 3

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

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí