Easy Topic Classifier on iOS with Apple’s Natural Language Framework

How to take advantage of Apple’s APIs with already-existing data for text classification

I’ve been working on a personal project recently, in which I need to know the main topic of a user’s generated text. So I made a tag system, which means that the user can tag his post based on the topic. I thought that this would suffice to classify the posts based on topics, but I was wrong.

The user can create a post with some text and optionally an image, and has to add a least one tag:

I had hoped that users would use the right tags and be genuine, but I noticed that users actually try to add as many tags as possible, which can lead to a very confusing recommendation and classification problem.

In this article, I’ll create a small model that can classify text based on the topic and implement it on in iOS application.

Overview:

  1. What is Apple’s “Natural Language” library?
  2. Find the dataset and preprocess it
  3. Train the model
  4. Evaluate the model
  5. Build the iOS Application
  6. Conclusion

What’s Apple’s “Natural Language” library?

To process plain text from content, you have to rely on
advanced natural language processing tools. This discipline, which combines computer science and computational linguistics, allows us to do text processing to extract semantics, context, vocabulary and many other characteristics essential to understanding the nature of the text

Given this, we can apply the power of artificial intelligence to perform tasks such as spell checking, text summarization, document classification, and content generation, among others.

Natural Language” is Apple’s framework/library for NLP. It’s very powerful and easy to use. You can use it to process any kind of text.

Finding and preprocessing the dataset

I have found a .CSV file on GitHub with five classes and a little bit more than 2000 text examples. Specifically, it consists of 2225 documents from the BBC news website, corresponding to stories in five topical areas from 2004–2005

If you have your own text, you have to make sure that the “annotation” is correct and that you have enough texts to cover some of the anomalies and edge cases. In my personal project, I had to clean up some of the data and manually tag the text with the appropriate topic.

To download the .CSV file, open the terminal and write this line:

Now that we have the .CSV, we need to convert it into a .JSON file so that it can be correctly parsed by the Create ML APIs, which is what we’ll be using to build our model (the .JSON file is in the GitHub repository as dataset-topic.json)

Training the model:

As mentioned above, we’ll be using Create ML to train the model. Luckily, Create ML is pretty straightforward and simple.

Open a new Playground and add the following code—I’ve commented every line to explain all the steps:

import CreateML
import Foundation

//Don't forget to change the path according to where your json file is located
let myJsonFilePath = "/Users/omarmhaimdat/Downloads/dataset-topic.json"

//Get an URL variable from the string
let url = URL(fileURLWithPath: myJsonFilePath)

//Translate the Json file to a MLDataTable
let trainingData = try MLDataTable(contentsOf: url)

//Train the model and specify the name of text and label column, as well as the training data
let model = try MLTextClassifier(trainingData: trainingData, textColumn: "news", labelColumn: "type")

//You can change where you want to save the model
try model.write(to: URL(fileURLWithPath: "/Users/omarmhaimdat/Desktop/TopicClassifier"))

Evaluate the model:

Now that we have the model in the form of a .mlmodel file (i.e. ready to be used in iOS), we need to test it against real data:

You can go to Google news or Yahoo news to find articles and use the text from the article to evaluate the model:

import NaturalLanguage
import Foundation
import CoreML

//Create a string with model path, change it according to where the model is stored
let modelPath = "/Users/omarmhaimdat/Desktop/TopicClassifier.mlmodel"

//Get a URL variable the string variable
let url = URL(fileURLWithPath: modelPath)

//Compile the mlmodel file
let compiledModel = try MLModel.compileModel(at: url)

//Get the Natural language model
let model = try NLModel(contentsOf: compiledModel)

//Get the inference
let label = model.predictedLabel(for: "When Nobel Laureate and Irrational Exuberance author Robert Shiller says he sees bubbles in the financial markets — you'd better listen up. I see bubbles everywhere, Shiller, economics professor at Yale University and author of just-published Narrative Economics told investors gathered in Los Angeles Wednesday. You invest even though you expect the price to decline. Shiller famously predicted the 2000 stock market crash and the 2007 crash of the housing market.")

Build the iOS Application (Dark mode ready):

Create a new project:

To begin, we need to create an iOS project with a single view app. Make sure to choose Storyboard in the “User interface” dropdown menu (Xcode 11 only):

Now we have our project ready to go. I don’t like using storyboards myself, so the app in this tutorial is built programmatically, which means no buttons or switches to toggle — just pure code 🤗.

To follow this method, you’ll have to delete the main.storyboard and set your SceneDelegate.swift file (Xcode 11 only) like so:

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
        // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
        // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
        guard let windowScene = (scene as? UIWindowScene) else { return }
        
        window = UIWindow(frame: windowScene.coordinateSpace.bounds)
        window?.windowScene = windowScene
        window?.rootViewController = ViewController()
        window?.makeKeyAndVisible()
    }

With Xcode 11, you’ll have to change the Info.plist file like so:

You need to delete the “Storyboard Name” in the file, and that’s about it.

Create View Controller

We only need one View Controllers:

  • ViewController():

This is where we’ll set our UICollectionView with all the text and show the text’s topic.

Create a navigation

  • MainTabBarController():

This is our main navigation where we’ll create the full navigation structure for our application.

Navigation in iOS is pretty straightforward and easy to implement. Here, I’ve changed some things like the font for the navigation bar, etc. so it looks nice.

class MainTabBarController: UITabBarController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupTabBar()
        if #available(iOS 13.0, *) {
           tabBar.backgroundColor = UIColor.label
        } else {
           // Fallback on earlier versions
           tabBar.backgroundColor = UIColor.black
        }
        tabBar.isTranslucent = true
    }
    
    func setupTabBar() {
        
        let vc = ViewController()
        
        let viewController = UINavigationController(rootViewController: vc)
        viewController.tabBarItem.image = UIImage(systemName: "text.badge.plus")?.withTintColor(.white)
        viewController.tabBarItem.title = "News"
        
        viewControllers = [viewController]
        
        UITabBar.appearance().tintColor = .label
        let navigation = UINavigationBar.appearance()
        
        let navigationFont = UIFont(name: "Avenir", size: 20)
        let navigationLargeFont = UIFont(name: "Avenir-Heavy", size: 34)
        
        navigation.titleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.black, NSAttributedString.Key.font: navigationFont!]
        
        if #available(iOS 11, *) {
            navigation.largeTitleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.black, NSAttributedString.Key.font: navigationLargeFont!]
        }
    }
}

Setup a UICollectionView — ViewController.swift

Create and setup the collection’s layout

First, instantiate a UICollectionView object and cellId object:

    let cellId = "cellId"
    
    var newCollection: UICollectionView = {
        
        let collection = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout())
        collection.backgroundColor = UIColor.clear
        collection.translatesAutoresizingMaskIntoConstraints = false
        collection.isScrollEnabled = true
        collection.showsVerticalScrollIndicator = false
        collection.allowsMultipleSelection = true
        
        return collection
    }()

Then set up the layout and add delegates:

    func setupCollection() {
        
        self.view.addSubview(newCollection)
        
        newCollection.centerXAnchor.constraint(equalTo: self.view.centerXAnchor).isActive = true
        newCollection.centerYAnchor.constraint(equalTo: self.view.centerYAnchor).isActive = true
        newCollection.heightAnchor.constraint(equalToConstant: view.frame.height).isActive = true
        newCollection.widthAnchor.constraint(equalToConstant: view.frame.width).isActive = true
        newCollection.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true
        newCollection.bottomAnchor.constraint(equalTo: self.view.bottomAnchor).isActive = true
        
    }
    
    func setupCollectionView() {
        if #available(iOS 13.0, *) {
            newCollection.backgroundColor = UIColor.systemBackground
        } else {
            // Fallback on earlier versions
           newCollection.backgroundColor = .white
        }
        newCollection.register(ViewControllerCell.self, forCellWithReuseIdentifier: cellId)
        newCollection.alwaysBounceVertical = true
        newCollection.delegate = self
        newCollection.dataSource = self
        
    }

We also need a CollectionViewCell. I’ve created a custom one, but you can obviously use the default one:

class ViewControllerCell: UICollectionViewCell {
    
    var cornerColor: UIColor?
    var primaryColor: UIColor?
    var secondaryColor: UIColor?
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupCell()
    }
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override var isHighlighted: Bool {
        didSet {
            
            UIView.animate(withDuration: 0.27, delay: 0.0, usingSpringWithDamping: 1, initialSpringVelocity: 1.0, options: [.curveEaseOut, .beginFromCurrentState], animations: {
                self.contentView.alpha = self.isHighlighted ? 0.35 : 1
                self.transform = self.isHighlighted ? self.transform.scaledBy(x: 0.96, y: 0.96) : .identity
            })
        }
    }
    
    override var isSelected: Bool {
        didSet {
            if isSelected {
                self.contentView.layer.borderColor = secondaryColor?.cgColor
                self.contentView.backgroundColor = secondaryColor
            } else {
                self.contentView.layer.borderColor = cornerColor?.cgColor
                self.contentView.backgroundColor = primaryColor
            }
        }
    }
    
    // MARK: UI
    let topic: UILabel = {
        let label = UILabel()
        label.text = "....."
        label.textColor = UIColor.label
        label.font = UIFont.boldSystemFont(ofSize: 16)
        label.textAlignment = .left
        label.numberOfLines = 1
        
        return label
    }()
    
    let text: UILabel = {
        let label = UILabel()
        label.text = "........................."
        label.textColor = UIColor.label
        label.font = UIFont.boldSystemFont(ofSize: 12)
        label.textAlignment = .left
        label.numberOfLines = 0
        label.sizeToFit()
        label.lineBreakMode = .byWordWrapping
        
        return label
    }()
    
    // MARK: Setup Cell
    fileprivate func setupCell() {
        roundCorner()
        setCellShadow()
        self.addSubview(topic)
        self.addSubview(text)
        
        topic.anchor(top: safeTopAnchor, left: safeLeftAnchor, bottom: nil, right: safeRightAnchor, paddingTop: 15, paddingLeft: 20, paddingBottom: 0, paddingRight: 20)
        
        text.anchor(top: topic.bottomAnchor, left: safeLeftAnchor, bottom: safeBottomAnchor, right: safeRightAnchor, paddingTop: 10, paddingLeft: 20, paddingBottom: -25, paddingRight: 20)
    }
    
    // MARK: Methods
    func setCellShadow() {
        self.layer.shadowColor = UIColor.black.cgColor
        self.layer.shadowOffset = CGSize(width: 0, height: 1)
        self.layer.shadowOpacity = 0.2
        self.layer.shadowRadius = 6.0
        self.layer.masksToBounds = false
        self.layer.cornerRadius = 14
        self.clipsToBounds = false
    }
    
    func roundCorner() {
        self.contentView.layer.cornerRadius = 14
        self.contentView.layer.masksToBounds = true
        self.contentView.layer.borderWidth = 3.0
        self.contentView.layer.borderColor = cornerColor?.cgColor
        self.contentView.backgroundColor = .systemBackground
    }
    
}

Populate the collection view

We need a model to host our text and the prediction, so for the sake of simplicity, I’ve created a struct called “Model”:

struct Model {
    var text: String
    var color: UIColor
    var secondaryColor: UIColor
    var topic: String
    
    init(text: String, color: UIColor, topic: String, secondaryColor: UIColor) {
        self.text = text
        self.color = color
        self.secondaryColor = secondaryColor
        self.topic = topic
    }
}

We also need to create an array of Models that will be used to populate the UICollectionView:

Now we have an array of models we can use to populate the collection. I prefer to use extensions, so I’ve created a separate file:

extension ViewController: UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout {
    
    func numberOfSections(in collectionView: UICollectionView) -> Int {
        return 1
    }
    
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return self.cells.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellId, for: indexPath) as! ViewControllerCell
        
        cell.topic.text = "(self.cells[indexPath.row].topic)"
        cell.text.text = self.cells[indexPath.row].text
        cell.contentView.layer.borderColor = self.cells[indexPath.row].color.cgColor
        cell.cornerColor = self.cells[indexPath.row].color
        cell.contentView.backgroundColor = self.cells[indexPath.row].color
        cell.primaryColor = self.cells[indexPath.row].color
        cell.secondaryColor = self.cells[indexPath.row].secondaryColor
        return cell
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        
        let width = round((view.frame.width - 40) / 2.0)*2
        
        let height = self.cells[indexPath.row].text.heightWithConstrainedWidth(width: width, font: UIFont.boldSystemFont(ofSize: 12)) + 90
        
        return CGSize(width: width, height: height)
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
        return UIEdgeInsets(top: 25, left: 0, bottom: 25, right: 0)
    }
    
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        switch mMode {
        case .view:
            newCollection.deselectItem(at: indexPath, animated: true)
        case .select:
            dictionarySelectedIndexPath[indexPath] = true
        }
    }
    
    func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) {
        if mMode == .select {
            dictionarySelectedIndexPath[indexPath] = false
        }
    }
}

I’ve also added a small extension for the String class so that I can calculate the height of the text and resize the collection view cells dynamically.

Add new text:

We need to setup the tabBar and add a rightBarButtonItem with a ‘plus’ sign button that will allow our users to add text and predict the topic.

This button should also trigger a UIAlertController with a TextField:

func setupTabBar() {
        navigationController?.navigationBar.prefersLargeTitles = true
        self.navigationItem.title = "News Classifier"
        if #available(iOS 13.0, *) {
            self.navigationController?.navigationBar.barTintColor = .systemBackground
             navigationController?.navigationBar.titleTextAttributes = [.foregroundColor : UIColor.label]
        } else {
            // Fallback on earlier versions
            self.navigationController?.navigationBar.barTintColor = .lightText
            navigationController?.navigationBar.titleTextAttributes = [.foregroundColor : UIColor.black]
        }
        self.navigationController?.navigationBar.isHidden = false
        self.setNeedsStatusBarAppearanceUpdate()
        self.navigationItem.largeTitleDisplayMode = .automatic
        self.navigationController?.navigationBar.barStyle = .default
        if #available(iOS 13.0, *) {
            navigationController?.navigationBar.largeTitleTextAttributes = [.foregroundColor : UIColor.label]
        } else {
            navigationController?.navigationBar.largeTitleTextAttributes = [.foregroundColor : UIColor.black]
        }
        if #available(iOS 13.0, *) {
            navigationController?.navigationBar.backgroundColor = .systemBackground
        } else {
            // Fallback on earlier versions
            navigationController?.navigationBar.backgroundColor = .white
        }
        self.tabBarController?.tabBar.isHidden = false
        self.navigationItem.rightBarButtonItem = .init(barButtonSystemItem: .add, target: self, action: #selector(addCell(_:)))
        self.navigationItem.leftBarButtonItem = .init(barButtonSystemItem: .edit, target: self, action: #selector(editCell(_:)))
    }

Get the inference and populate the CollectionView :

@objc func addCell(_ sender: UIBarButtonItem) {
        
        let model = TopicClassifier()
        
        let alertController = UIAlertController(title: "", message: "", preferredStyle: .alert)
        alertController.addTextField { textField in
            textField.placeholder = "Text"
        }
        let confirmAction = UIAlertAction(title: "Add", style: .default) { [weak alertController] _ in
            guard let alertController = alertController, let textField = alertController.textFields?.first else { return }
            if let text = textField.text {
                do {
                    let prediction = try model.prediction(text: text)
                    if prediction.label == "business" {
                        let cell = Model(text: text, color: UIColor.systemBlue, topic: prediction.label, secondaryColor: UIColor.systemGray6)
                        self.cells.append(cell)
                        self.newCollection.reloadData()
                    } else if prediction.label == "tech" {
                        let cell = Model(text: text, color: UIColor.systemGreen, topic: prediction.label, secondaryColor: UIColor.systemGray6)
                        self.cells.append(cell)
                        self.newCollection.reloadData()
                    } else if prediction.label == "entertainment" {
                        let cell = Model(text: text, color: UIColor.systemOrange, topic: prediction.label, secondaryColor: UIColor.systemGray6)
                        self.cells.append(cell)
                        self.newCollection.reloadData()
                    } else if prediction.label == "politics" {
                        let cell = Model(text: text, color: UIColor.systemRed, topic: prediction.label, secondaryColor: UIColor.systemGray6)
                        self.cells.append(cell)
                        self.newCollection.reloadData()
                    } else if prediction.label == "sport" {
                        let cell = Model(text: text, color: UIColor.systemTeal, topic: prediction.label, secondaryColor: UIColor.systemGray6)
                        self.cells.append(cell)
                        self.newCollection.reloadData()
                    }
                } catch {
                    print(error)
                }
            }
        }
        alertController.addAction(confirmAction)
        let cancelAction = UIAlertAction(title: "Cancel", style: .destructive, handler: nil)
        alertController.addAction(cancelAction)
        present(alertController, animated: true, completion: nil)
    }

I randomly choose some articles from Google News:

Here’s the result:

Conclusion

I cannot emphasize how easy it is to create a text classification model with Apple’s APIs. One of the best parts is that the model lives on the phone and requires no communication with external servers. Every iOS developer should take advantage of this technology to improve user engagement or add new features to their applications—all with minimal effort.

I should also add that the majority of the dataset is already there, you just have to use it.

If you liked this tutorial, please share it with your friends. If you have any questions don’t hesitate to send me an email at [email protected].

This project is available to download from my GitHub account.

Fritz

Our team has been at the forefront of Artificial Intelligence and Machine Learning research for more than 15 years and we're using our collective intelligence to help others learn, understand and grow using these new technologies in ethical and sustainable ways.

Comments 0 Responses

Leave a Reply

Your email address will not be published. Required fields are marked *

wix banner square