1. Zanim zaczniesz
W tym ćwiczeniu z programowania zaktualizujesz aplikację utworzoną w poprzednim ćwiczeniu z programowania z klasyfikacją tekstu na urządzeniu mobilnym.
Wymagania wstępne
- To laboratorium programistyczne zostało przygotowane dla doświadczonych programistów, którzy dopiero zaczynają swoją przygodę z uczenie maszynowe.
- Codelab jest częścią sekwencyjnego ścieżki. Jeśli nie masz jeszcze za sobą poprzedniego samouczka na temat tworzenia aplikacji w stylu podstawowej aplikacji do przesyłania wiadomości lub tworzenia modelu systemu uczącego się do wykrywania spamu w komentarzach, zrealizuj je teraz.
Czego się dowiesz?
- Dowiesz się, jak zintegrować z aplikacją utworzony w poprzednich krokach model niestandardowy.
Czego potrzebujesz
- Android Studio lub CocoaPods na iOS
2. Otwórz istniejącą aplikację na Androida
Aby uzyskać odpowiedni kod, wykonaj instrukcje z Codelab 1 lub skopiuj to repozytorium i wczytaj aplikację ze strony TextClassificationStep1
.
git clone https://github.com/googlecodelabs/odml-pathways
Znajdziesz ją na ścieżce TextClassificationOnMobile->Android
.
Kod gotowy jest też dostępny jako TextClassificationStep2
.
Gdy się otworzy, możesz przejść do kroku 2.
3. Importowanie pliku modelu i metadanych
W ramach ćwiczenia Create a comment spam machine learning model (Tworzenie modelu systemów uczących się do wykrywania spamu w komentarzach) utworzyłeś/utworzyłaś model .TFLITE.
Plik modelu powinien zostać pobrany. Jeśli go nie masz, możesz go pobrać z repozytorium tego CodeLab. Model jest dostępny tutaj.
Dodaj go do projektu, tworząc katalog zasobów.
- W nawigatorze projektów wybierz Android u góry.
- Kliknij prawym przyciskiem myszy folder aplikacji. Kliknij Nowy > Katalog.
- W oknie Nowy katalog wybierz src/main/assets.
Pojawi się nowy folder assets w aplikacji.
- Kliknij prawym przyciskiem myszy zasoby.
- W menu, które się pojawi, zobaczysz (na Macu) Pokaż w Finderze. Wybierz ją. (w systemie Windows będzie to Pokaż w Eksploratorze, a w Ubuntu Pokaż w plikach).
Uruchomi się Finder, aby wyświetlić lokalizację plików (Eksplorator plików w systemie Windows, Files w systemie Linux).
- Skopiuj do tego katalogu pliki
labels.txt
,model.tflite
ivocab
.
- Wróć do Android Studio, a pliki te będą dostępne w folderze assets.
4. Zaktualizuj plik build.gradle, aby używać TensorFlow Lite
Aby używać TensorFlow Lite i obsługujących go bibliotek zadań TensorFlow Lite, musisz zaktualizować plik build.gradle
.
Projekty na Androida często mają więcej niż jeden projekt, dlatego pamiętaj, aby znaleźć pierwszą aplikację. W eksploratorze projektu w widoku Androida znajdź go w sekcji Skrypty Gradle. Prawidłowy plik będzie oznaczony jako .app, jak tutaj:
W tym pliku musisz wprowadzić 2 zmiany. Pierwszy znajduje się w sekcji zależność na dole. Dodaj tekst implementation
dla biblioteki zadań TensorFlow Lite, na przykład:
implementation 'org.tensorflow:tensorflow-lite-task-text:0.1.0'
Od czasu napisania tego artykułu numer wersji mógł się zmienić, dlatego sprawdź najnowszą wersję na stronie https://www.tensorflow.org/lite/inference_with_metadata/task_library/nl_classifier.
Biblioteki zadań wymagają też co najmniej wersji 21 pakietu SDK. Znajdź to ustawienie w sekcji android
> default config
i zmień je na 21:
Masz już wszystkie zależności, więc czas zacząć kodować.
5. Dodaj klasę pomocniczą
Aby oddzielić logikę wnioskowania, w której aplikacja używa modelu, od interfejsu użytkownika, utwórz kolejną klasę do obsługi wnioskowania modelu. Nazwij to klasą „pomocną”.
- Kliknij prawym przyciskiem myszy nazwę pakietu, w którym znajduje się Twój kod
MainActivity
. - Kliknij Nowy > Pakiet.
- W środku ekranu pojawi się okno z prośbą o wpisanie nazwy pakietu. Dodaj go na końcu bieżącej nazwy pakietu. (tutaj nazywa się helpers).
- Następnie w eksploratorze projektu kliknij prawym przyciskiem folder helpers.
- Kliknij Nowy > Klasa Java i nazwij ją
TextClassificationClient
. W następnym kroku zmodyfikujesz plik.
Twoja klasa pomocnicza TextClassificationClient
będzie wyglądać tak (choć nazwa pakietu może być inna).
package com.google.devrel.textclassificationstep1.helpers;
public class TextClassificationClient {
}
- Zaktualizuj plik za pomocą tego kodu:
package com.google.devrel.textclassificationstep2.helpers;
import android.content.Context;
import android.util.Log;
import java.io.IOException;
import java.util.List;
import org.tensorflow.lite.support.label.Category;
import org.tensorflow.lite.task.text.nlclassifier.NLClassifier;
public class TextClassificationClient {
private static final String MODEL_PATH = "model.tflite";
private static final String TAG = "CommentSpam";
private final Context context;
NLClassifier classifier;
public TextClassificationClient(Context context) {
this.context = context;
}
public void load() {
try {
classifier = NLClassifier.createFromFile(context, MODEL_PATH);
} catch (IOException e) {
Log.e(TAG, e.getMessage());
}
}
public void unload() {
classifier.close();
classifier = null;
}
public List<Category> classify(String text) {
List<Category> apiResults = classifier.classify(text);
return apiResults;
}
}
Ta klasa udostępnia zewnętrzną implementację dla interpretera TensorFlow Lite, wczytuje model i ukrywa złożoność zarządzania wymianą danych między aplikacją a modelem.
W metodzie load()
zostanie utworzony nowy typ NLClassifier
na podstawie ścieżki modelu. Ścieżka modelu to po prostu nazwa modelu, model.tflite
. Typ NLClassifier
jest częścią bibliotek zadań tekstowych. Pomaga konwertować ciąg znaków na tokeny przy użyciu właściwej długości sekwencji, przekazywać go do modelu i analizować wyniki.
(więcej informacji na ten temat znajdziesz w artykule Tworzenie modelu systemu uczącego się do wykrywania spamu w komentarzach).
Klasyfikacja jest wykonywana w ramach metody classify, do której przekazujesz ciąg znaków. Metoda zwraca wartość List
. Gdy używasz modeli systemów uczących się do klasyfikowania treści, aby określić, czy ciąg znaków jest spamem, zwykle zwracane są wszystkie odpowiedzi z przypisanymi prawdopodobieństwami. Jeśli na przykład przekażesz wiadomość, która wygląda jak spam, otrzymasz listę 2 odpowiedzi: jedną z prawdopodobieństwem, że jest to spam, i druga, która według Ciebie prawdopodobnie nie jest spamem. Elementy typu Spam/Nie spam to kategorie, więc zwrócone dane List
będą zawierać te prawdopodobieństwa. Dowiesz się tego później.
Gdy już utworzysz klasę pomocniczą, wróć do funkcji MainActivity
i zaktualizuj ją, aby wykorzystywać ją do klasyfikowania tekstu. W następnym kroku zobaczysz to.
6. Klasyfikowanie tekstu
W MainActivity
musisz najpierw zaimportować utworzone przez siebie pomocnicze pliki.
- U góry
MainActivity.kt
, razem z innymi importami, dodaj:
import com.google.devrel.textclassificationstep2.helpers.TextClassificationClient
import org.tensorflow.lite.support.label.Category
- Teraz musisz wczytać pomocnicze pliki pomocniczych. W pliku
onCreate
, tuż po wierszusetContentView
, dodaj te wiersze, aby utworzyć instancję klasy pomocniczej i ją załadować:
val client = TextClassificationClient(applicationContext)
client.load()
Obecnie przycisk onClickListener
powinien wyglądać tak:
btnSendText.setOnClickListener {
var toSend:String = txtInput.text.toString()
txtOutput.text = toSend
}
- Zmień go w ten sposób:
btnSendText.setOnClickListener {
var toSend:String = txtInput.text.toString()
var results:List<Category> = client.classify(toSend)
val score = results[1].score
if(score>0.8){
txtOutput.text = "Your message was detected as spam with a score of " + score.toString() + " and not sent!"
} else {
txtOutput.text = "Message sent! \nSpam score was:" + score.toString()
}
txtInput.text.clear()
}
Dzięki temu funkcja nie tylko wyświetla dane wejściowe użytkownika, ale najpierw je klasyfikuje.
- Dzięki temu wierszowi możesz pobrać ciąg znaków wpisany przez użytkownika i przekazać go do modelu, aby uzyskać wyniki:
var results:List<Category> = client.classify(toSend)
Są tylko 2 kategorie: False
i True
(TensorFlow sortuje je alfabetycznie, więc wartość False będzie elementem 0, a True – elementem 1).
- Aby uzyskać wynik prawdopodobieństwa, że wartość jest
True
, możesz sprawdzić element results[1].score w ten sposób:
val score = results[1].score
- Wybrana wartość progowa (w tym przypadku 0,8), która określa, że jeśli wynik dla kategorii Prawda jest wyższy niż wartość progowa (0,8), wiadomość jest spamem. W przeciwnym razie nie jest to spam i wiadomość można bezpiecznie wysłać:
if(score>0.8){
txtOutput.text = "Your message was detected as spam with a score of " + score.toString() + " and not sent!"
} else {
txtOutput.text = "Message sent! \nSpam score was:" + score.toString()
}
- Zobacz model w praktyce. Wiadomość „Odwiedź mój blog, aby kupić coś dla siebie” została oznaczona jako spam:
Z kolei „Hej, fajny samouczek, dzięki!” został uznany za mało prawdopodobny spam:
7. Aktualizowanie aplikacji na iOS, aby używać modelu TensorFlow Lite
Aby uzyskać odpowiedni kod, wykonaj instrukcje z Codelab 1 lub skopiuj to repozytorium i wczytaj aplikację ze strony TextClassificationStep1
. Możesz go znaleźć w ścieżce TextClassificationOnMobile->iOS
.
Kod gotowy jest też dostępny jako TextClassificationStep2
.
W ramach ćwiczenia w Codelab Tworzenie modelu uczenia maszynowego do wykrywania spamu w komentarzach utworzysz bardzo prostą aplikację, która pozwala użytkownikowi wpisać wiadomość w polu UITextView
i przekazać ją do wyjścia bez żadnego filtrowania.
Teraz zaktualizuj aplikację, aby używała modelu TensorFlow Lite do wykrywania spamu w komentarzach przed wysłaniem. Wystarczy symulować wysyłanie wiadomości w tej aplikacji, renderując tekst w etykiecie wyjściowej (prawdziwa aplikacja może mieć tablicę ogłoszeń, czat lub coś podobnego).
Aby rozpocząć, musisz skopiować aplikację z repozytorium z etapu 1.
Aby włączyć TensorFlow Lite, użyj CocoaPods. Jeśli nie masz jeszcze zainstalowanych tych pakietów, możesz to zrobić, postępując zgodnie z instrukcjami pod adresem https://cocoapods.org/.
- Po zainstalowaniu CocoaPods utwórz plik o nazwie Podfile w tym samym katalogu, w którym znajduje się plik
.xcproject
aplikacji TextClassification. Treść tego pliku powinna wyglądać tak:
target 'TextClassificationStep2' do
use_frameworks!
# Pods for NLPClassifier
pod 'TensorFlowLiteSwift'
end
W pierwszym wierszu powinno znaleźć się nazwę aplikacji, a nie „TextClassificationStep2”.
Używając Terminala, przejdź do tego katalogu i uruchom pod install
. Jeśli się powiedzie, utworzy się nowy katalog o nazwie Pods i nowy plik .xcworkspace
. W przyszłości będziesz używać tego operatora zamiast operatora .xcproject
.
Jeśli się nie powiedzie, sprawdź, czy plik Podfile znajduje się w tym samym katalogu, w którym był plik .xcproject
. Zwykle główną przyczyną problemów jest plik podfile znajdujący się w niewłaściwym katalogu lub nieprawidłowa nazwa miejsca docelowego.
8. Dodawanie plików modelu i słownika
Po utworzeniu modelu za pomocą Kreatora modeli TensorFlow Lite można go wyświetlić (jako model.tflite
) i wokab (jako vocab.txt
).
- Dodaj je do projektu, przeciągając je z Findera do okna projektu. Upewnij się, że zaznaczone jest pole Dodaj do listy docelowych:
Gdy skończysz, powinny pojawić się w projekcie:
- Sprawdź, czy zostały one dodane do pakietu (aby można je było wdrożyć na urządzeniu). Aby to zrobić, wybierz projekt (na powyższym zrzucie ekranu jest to niebieska ikona TextClassificationStep2) i otwórz kartę Etapy kompilacji:
9. Wczytaj słownictwo
Podczas klasyfikacji NLP model jest trenowany za pomocą słów zakodowanych w wektory. Model koduje słowa za pomocą określonego zbioru nazw i wartości, które są uczone podczas trenowania modelu. Pamiętaj, że większość modeli ma różne słowniki, dlatego ważne jest, aby używać słownika dla modelu wygenerowanego podczas trenowania. To plik vocab.txt
, który właśnie dodałeś/dodałaś do aplikacji.
Aby zobaczyć kodowanie, otwórz plik w Xcode. Słowa takie jak „piosenka” są kodowane na 6, a „miłość” na 12. Kolejność jest w rzeczywistości kolejność występowania, więc „I” było najczęstszym słowem w zbiorze danych, a za nim następowało „check”.
Gdy użytkownik wpisze słowa, przed wysłaniem ich do modelu do klasyfikacji należy je zakodować za pomocą tej słownictwa.
Przyjrzyjmy się temu kodowi. Zacznij od załadowania słownictwa.
- Zdefiniuj zmienną na poziomie klasy, w której będziesz przechowywać słownik:
var words_dictionary = [String : Int]()
- Następnie utwórz
func
na zajęciach, aby załadować słownictwo do tego słownika:
func loadVocab(){
// This func will take the file at vocab.txt and load it into a has table
// called words_dictionary. This will be used to tokenize the words before passing them
// to the model trained by TensorFlow Lite Model Maker
if let filePath = Bundle.main.path(forResource: "vocab", ofType: "txt") {
do {
let dictionary_contents = try String(contentsOfFile: filePath)
let lines = dictionary_contents.split(whereSeparator: \.isNewline)
for line in lines{
let tokens = line.components(separatedBy: " ")
let key = String(tokens[0])
let value = Int(tokens[1])
words_dictionary[key] = value
}
} catch {
print("Error vocab could not be loaded")
}
} else {
print("Error -- vocab file not found")
}
}
- Możesz uruchomić tę funkcję, wywołując ją z poziomu
viewDidLoad
:
override func viewDidLoad() {
super.viewDidLoad()
txtInput.delegate = self
loadVocab()
}
10. Przekształcanie ciągu w sekwencję tokenów
Użytkownicy wpisują słowa w postaci zdania, które staje się ciągiem znaków. Każde słowo w zrzucie, jeśli występuje w słowniku, zostanie zakodowane w kluczu wartości dla tego słowa zgodnie z definicją w słowniku.
Model NLP zwykle akceptuje stałą długość sekwencji. W przypadku modeli utworzonych za pomocą funkcji ragged tensors
mogą wystąpić wyjątki, ale w większości przypadków jest to stała wartość. Podczas tworzenia modelu określono tę długość. Upewnij się, że w aplikacji na iOS używasz tej samej długości.
Domyślna wartość w narzędzie Colab do tworzenia modeli TensorFlow Lite, którego używaliśmy wcześniej, wynosiła 20, więc ustaw ją tutaj:
let SEQUENCE_LENGTH = 20
Dodaj funkcję func
, która przyjmie ciąg znaków, przekształci go na małe litery i usunie wszystkie znaki interpunkcyjne:
func convert_sentence(sentence: String) -> [Int32]{
// This func will split a sentence into individual words, while stripping punctuation
// If the word is present in the dictionary it's value from the dictionary will be added to
// the sequence. Otherwise we'll continue
// Initialize the sequence to be all 0s, and the length to be determined
// by the const SEQUENCE_LENGTH. This should be the same length as the
// sequences that the model was trained for
var sequence = [Int32](repeating: 0, count: SEQUENCE_LENGTH)
var words : [String] = []
sentence.enumerateSubstrings(
in: sentence.startIndex..<sentence.endIndex,options: .byWords) {
(substring, _, _, _) -> () in words.append(substring!) }
var thisWord = 0
for word in words{
if (thisWord>=SEQUENCE_LENGTH){
break
}
let seekword = word.lowercased()
if let val = words_dictionary[seekword]{
sequence[thisWord]=Int32(val)
thisWord = thisWord + 1
}
}
return sequence
}
Uwaga: sekwencja będzie miała typ Int32. Jest to celowo wybrane, ponieważ podczas przekazywania wartości do TensorFlow Lite będziesz mieć do czynienia z pamięcią niskiego poziomu, a TensorFlow Lite traktuje liczby całkowite w sekwencji ciągów jako 32-bitowe liczby całkowite. Ułatwi Ci to (trochę) przekazywanie ciągów znaków do modelu.
11. Klasyfikacja
Aby sklasyfikować zdanie, należy je najpierw przekształcić w sekwencję tokenów opartą na słowach w zdaniu. To zostało zrobione w kroku 9.
Teraz wystarczy wziąć zdania, przekazać je modelowi, skonfigurować wnioskowanie przez model do zdania, a następnie przeanalizować wyniki.
Spowoduje to użycie interpretera TensorFlow Lite, który musisz zaimportować:
import TensorFlowLite
Zacznij od funkcji func
, która przyjmuje sekwencję, czyli tablicę typów Int32:
func classify(sequence: [Int32]){
// Model Path is the location of the model in the bundle
let modelPath = Bundle.main.path(forResource: "model", ofType: "tflite")
var interpreter: Interpreter
do{
interpreter = try Interpreter(modelPath: modelPath!)
} catch _{
print("Error loading model!")
return
}
Spowoduje to wczytanie pliku modelu z pakietu i wywołanie z nim interpretera.
Następnym krokiem będzie skopiowanie podstawowej pamięci zapisanej w sekwencji do bufora o nazwie myData,
, aby można było ją przekazać tensorowi. Podczas implementowania modułu TensorFlow Lite oraz interpretera uzyskasz dostęp do typu tensora.
Rozpocznij kod w ten sposób (nadal w klasyfikacji func
):
let tSequence = Array(sequence)
let myData = Data(copyingBufferOf: tSequence.map { Int32($0) })
let outputTensor: Tensor
Nie martw się, jeśli pojawi się błąd w copyingBufferOf
. Zostanie to zaimplementowane jako rozszerzenie w przyszłości.
Teraz czas na przydzielenie tensorów do interpretera, skopiowanie utworzonego właśnie bufora danych do tensora wejściowego i wywołanie interpretera do wykonania wnioskowania:
do {
// Allocate memory for the model's input `Tensor`s.
try interpreter.allocateTensors()
// Copy the data to the input `Tensor`.
try interpreter.copy(myData, toInputAt: 0)
// Run inference by invoking the `Interpreter`.
try interpreter.invoke()
Po zakończeniu wywołania możesz sprawdzić dane wyjściowe tłumacza, aby zobaczyć wyniki.
Będą to wartości nieprzetworzone (4 bajty na neuron), które musisz odczytać i przekształcić. Ten model ma 2 neurony wyjściowe, więc musisz odczytać 8 bajtów, które zostaną przekonwertowane na liczby zmiennoprzecinkowe 32-bitowe na potrzeby analizy. Masz do czynienia z pamięcią niskiego poziomu, stąd unsafeData
.
// Get the output `Tensor` to process the inference results.
outputTensor = try interpreter.output(at: 0)
// Turn the output tensor into an array. This will have 2 values
// Value at index 0 is the probability of negative sentiment
// Value at index 1 is the probability of positive sentiment
let resultsArray = outputTensor.data
let results: [Float32] = [Float32](unsafeData: resultsArray) ?? []
Teraz można stosunkowo łatwo przeanalizować dane, aby określić jakość spamu. Model ma 2 wyjścia: pierwsze z prawdopodobieństwom, że wiadomość nie jest spamem, a drugie z prawdopodobieństwom, że jest. Aby znaleźć wartość spamu, możesz sprawdzić results[1]
:
let positiveSpamValue = results[1]
var outputString = ""
if(positiveSpamValue>0.8){
outputString = "Message not sent. Spam detected with probability: " + String(positiveSpamValue)
} else {
outputString = "Message sent!"
}
txtOutput.text = outputString
Poniżej znajdziesz pełną metodę:
func classify(sequence: [Int32]){
// Model Path is the location of the model in the bundle
let modelPath = Bundle.main.path(forResource: "model", ofType: "tflite")
var interpreter: Interpreter
do{
interpreter = try Interpreter(modelPath: modelPath!)
} catch _{
print("Error loading model!")
Return
}
let tSequence = Array(sequence)
let myData = Data(copyingBufferOf: tSequence.map { Int32($0) })
let outputTensor: Tensor
do {
// Allocate memory for the model's input `Tensor`s.
try interpreter.allocateTensors()
// Copy the data to the input `Tensor`.
try interpreter.copy(myData, toInputAt: 0)
// Run inference by invoking the `Interpreter`.
try interpreter.invoke()
// Get the output `Tensor` to process the inference results.
outputTensor = try interpreter.output(at: 0)
// Turn the output tensor into an array. This will have 2 values
// Value at index 0 is the probability of negative sentiment
// Value at index 1 is the probability of positive sentiment
let resultsArray = outputTensor.data
let results: [Float32] = [Float32](unsafeData: resultsArray) ?? []
let positiveSpamValue = results[1]
var outputString = ""
if(positiveSpamValue>0.8){
outputString = "Message not sent. Spam detected with probability: " +
String(positiveSpamValue)
} else {
outputString = "Message sent!"
}
txtOutput.text = outputString
} catch let error {
print("Failed to invoke the interpreter with error: \(error.localizedDescription)")
}
}
12. Dodawanie rozszerzeń Swift
Powyższy kod używa rozszerzenia typu danych, aby umożliwić kopiowanie nieprzetworzonych bitów tablicy Int32 do zmiennej Data
. Oto kod tego rozszerzenia:
extension Data {
/// Creates a new buffer by copying the buffer pointer of the given array.
///
/// - Warning: The given array's element type `T` must be trivial in that it can be copied bit
/// for bit with no indirection or reference-counting operations; otherwise, reinterpreting
/// data from the resulting buffer has undefined behavior.
/// - Parameter array: An array with elements of type `T`.
init<T>(copyingBufferOf array: [T]) {
self = array.withUnsafeBufferPointer(Data.init)
}
}
Podczas pracy z pamięcią na niskim poziomie używasz danych „niebezpiecznych”, a powyższy kod wymaga zainicjowania tablicy danych niepewnych. To rozszerzenie umożliwia:
extension Array {
/// Creates a new array from the bytes of the given unsafe data.
///
/// - Warning: The array's `Element` type must be trivial in that it can be copied bit for bit
/// with no indirection or reference-counting operations; otherwise, copying the raw bytes in
/// the `unsafeData`'s buffer to a new array returns an unsafe copy.
/// - Note: Returns `nil` if `unsafeData.count` is not a multiple of
/// `MemoryLayout<Element>.stride`.
/// - Parameter unsafeData: The data containing the bytes to turn into an array.
init?(unsafeData: Data) {
guard unsafeData.count % MemoryLayout<Element>.stride == 0 else { return nil }
#if swift(>=5.0)
self = unsafeData.withUnsafeBytes { .init($0.bindMemory(to: Element.self)) }
#else
self = unsafeData.withUnsafeBytes {
.init(UnsafeBufferPointer<Element>(
start: $0,
count: unsafeData.count / MemoryLayout<Element>.stride
))
}
#endif // swift(>=5.0)
}
}
13. Uruchom aplikację na iOS
Uruchom i przetestuj aplikację.
Jeśli wszystko poszło dobrze, aplikacja na urządzeniu powinna wyglądać tak:
Gdy wysłano wiadomość „Kup moją książkę, aby dowiedzieć się więcej o inwestowaniu online”, aplikacja wysyła alert o wykryciu spamu z prawdopodobieństwo 0,99%.
14. Gratulacje!
Utworzyłeś/utworzyłaś bardzo prostą aplikację, która filtruje tekst pod kątem spamu w komentarzach, korzystając z modelu wytrenowanego na danych pochodzących z blogów zawierających spam.
Kolejnym krokiem w typowym cyklu życia dewelopera jest sprawdzenie, co jest potrzebne do dostosowania modelu na podstawie danych znalezionych w Twojej społeczności. W następnej ścieżce dowiesz się, jak to zrobić.