A modular Rust project for preparing a text sentiment dataset with robust tokenization and TF‑IDF feature extraction, saving portable artifacts for training and inference, and demonstrating how to load and use those artifacts. It also includes a hybrid Neuro‑Fuzzy layer on top of a neural network for interpretable post‑processing and a demo/evaluator to showcase it. The code emphasizes reproducibility, interpretability, and simple integration points for modeling.
This README documents the full pipeline, how to run it, the modules, data shapes, and practical tips/troubleshooting.
- Clean, configurable tokenizer (stopwords removal, min token length).
- TF‑IDF vectorizer with n‑gram support, min_df/max_df filtering, and optional max_features cap.
- Deterministic vocabulary ordering for reproducible features.
- Binary serialization of vectorizer and matrices (bincode) and human‑readable metadata (JSON).
- A ready‑to‑run data preparation CLI that loads CSV → tokenizes → TF‑IDF → train/test split → saves
data/. - A loader example CLI that shows how to consume the saved artifacts and transform new text.
src/bin/prepare_data.rs— End‑to‑end preprocessing pipeline into TF‑IDF and serialized artifacts.src/bin/load_data_example.rs— Loads the saved artifacts and demonstrates transforming new text.src/bin/neuro_fuzzy_demo.rs— Interactive REPL demo that runs the NN and Neuro‑Fuzzy hybrid, prints memberships, rule strengths, labels, and confidence.src/bin/evaluate.rs— Reports NN‑only vs Neuro‑Fuzzy accuracy on the test split fromdata/.src/preprocessing/tokenizer.rs— Text cleaning and tokenization utilities.src/tfidf/vectorizer.rs— Configurable TF‑IDF with n‑grams, term filtering, dense/sparse conversions.src/preprocessing/serialization.rs— Save/load vectorizer, matrices, labels, and metadata.src/NN/…— Minimal neural network utilities and atrain_modelfunction (example), for those who want to experiment further.src/utils.rs— Train/test split helpers, including a stratified variant.src/fuzzy/neuro_sugeno.rs— Neuro‑Fuzzy hybrid (NN‑driven memberships + Sugeno rules).src/fuzzy/params.rs— Centralized tuned parameters and default rule base shared by demo/evaluator.data/— Output folder for serialized artifacts created by the pipeline.
- Rust toolchain (stable). Install via https://rustup.rs if needed.
- CSV with headers and two columns:
text,label - Labels are expected to be numeric in { -1, 0, +1 } (negative, neutral, positive).
- Default path expected by the pipeline:
src/result.csv
Example (first few lines):
text,label
"I love this movie",1
"This is okay",0
"Absolutely terrible",-1Inputs → Preprocess → Split → Vectorize → Serialize → Validate → Done
- Load CSV
NN::io::load_sentiment_csv(path)reads a headered CSV and skips malformed/empty rows.- Prints class distribution for quick sanity checks.
- Tokenize
Tokenizer::new().with_stopwords(true).with_min_length(2)tokenize(&str)uses: clean punctuation → lowercase → filter by length → optional stopwords removal.
- Split
utils::train_test_splitshuffles deterministically (seed) and splits by ratio.- Optionally use
utils::stratified_train_test_splitto preserve class proportions.
- Fit TF‑IDF (train only)
TfIdfVectorizer::new()builder options:.max_features(usize)— keep top terms by document frequency cap (optional).min_df(usize)— ignore terms appearing in fewer docs than this.max_df(f32)— ignore overly frequent terms (as fraction of docs).ngram_range(min, max)— extract n‑grams; e.g.(1,2)includes unigrams + bigrams
- Fit computes smoothed IDF: idf(t) = ln((1+N) / (1+df(t))) + 1.
- Vocabulary is stored in deterministic index order (sorted lexicographically after filtering) for reproducibility.
- Transform train and test
- For each document,
transform(tokens)builds a sparse mapHashMap<usize, f32>of TF*IDF weights. to_dense(&sparse)converts toVec<f32>using the vocabulary size.
- Serialize
preprocessing::serialization::save_all("data", ...)writes:data/tfidf_vectorizer.bin— the vectorizer (bincode)data/tfidf_matrices.bin— train/test matrices (bincode)data/y_train.bin,data/y_test.bin— labels (bincode)data/metadata.json— human‑readable metadata (JSON)
- Validate
- Immediately reads everything back via
load_all("data")and asserts shapes match.
- Prepare data
cargo run --bin prepare_dataWhat it does:
- Reads
src/result.csv. - Tokenizes, splits, fits TF‑IDF, transforms train/test.
- Saves artifacts in
data/. - Prints metadata and a small feature preview.
- Load artifacts and transform new text (example)
cargo run --bin load_data_exampleWhat it does:
- Loads all artifacts from
data/. - Prints dataset summary and shows how to transform a custom sentence into TF‑IDF.
- Run the Neuro‑Fuzzy interactive demo
cargo run --bin neuro_fuzzy_demoWhat it does:
- Loads
data/artifacts and a trained model frommodel.bin. - For each input line, computes logits → softmax (with temperature) → Neuro‑Fuzzy memberships and rule strengths → hybrid score and label.
- Prints both the Neuro‑Fuzzy label (with a confidence heuristic) and the NN‑only argmax baseline.
- Evaluate accuracy on the test set
cargo run --bin evaluateWhat it does:
- Loads
data/test vectors and labels andmodel.bin. - Reports NN‑only accuracy and Neuro‑Fuzzy accuracy (using the shared tuned parameters and rules).
Load everything at once:
use sentiment_analysis::preprocessing::serialization;
let data = serialization::load_all("data")?;
// Feature dimensions and matrices
let d = data.vectorizer.vocab_size();
let x_train: Vec<Vec<f32>> = data.train_vectors;
let y_train: Vec<f32> = data.train_labels;
// Transform new text at inference time
use sentiment_analysis::preprocessing::tokenizer::Tokenizer;
let tok = Tokenizer::new().with_stopwords(true).with_min_length(2);
let tokens = tok.tokenize("An unexpectedly enjoyable film");
let sparse = data.vectorizer.transform(&tokens);
let dense = data.vectorizer.to_dense(&sparse);
assert_eq!(dense.len(), d);use sentiment_analysis::fuzzy::params;
use sentiment_analysis::fuzzy::neuro_sugeno::{InferLogits, NeuroSugeno};
// Your model type must implement InferLogits (see binaries for a simple adapter)
let model = /* load NN model, wrap in adapter */;
let nf: NeuroSugeno<_> = NeuroSugeno { provider: params::tuned_provider(model), rules: params::default_rules() };
let score = nf.infer_score(&dense);
let label = nf.infer_label_with_threshold(&dense, params::NF_LABEL_THRESHOLD); // -1, 0, +1-
preprocessing/tokenizer.rsclean_text(&str) -> String— strips punctuation to spaces, normalizes whitespace.tokenize_with_preprocessing(text, remove_stopwords)— one‑shot convenience.Tokenizer { remove_stopwords, min_token_length }with builder methods andtokenize(&str).
-
tfidf/vectorizer.rsTfIdfVectorizer::new() -> Builderfit(&mut self, corpus: &[Vec<String>])— build vocab and IDF from tokenized docs.transform(&self, tokens: &[String]) -> HashMap<usize, f32>— sparse TF*IDF.to_dense(&self, &HashMap<usize, f32>) -> Vec<f32>— dense vector.get_feature_names(&self) -> Vec<String>— index → term mapping.vocab_size(&self) -> usize— feature dimension.
-
preprocessing/serialization.rssave_all,load_allorchestrate binary+JSON artifacts.DatasetMetadatadocuments key preprocessing settings and sizes.
-
utils.rstrain_test_split— simple shuffled split.stratified_train_test_split— per‑class proportional split.
-
NN/(optional experimentation)- Contains a basic MLP scaffold and an example
train_modelfunction showing how to consume the TF‑IDF features. This is intentionally minimal and not wired to a CLI by default, so you can integrate with your own training loop or framework.
- Contains a basic MLP scaffold and an example
- Labels: stored as
f32with values in { -1.0, 0.0, +1.0 }. - Features:
Vec<f32>length =vectorizer.vocab_size(). - Train/Test sizes are saved in
metadata.jsonand should match matrix row counts. - Vocabulary indices are stable across save/load — crucial for consistent inference.
- Use bigrams only:
.ngram_range(2, 2)
- Larger vocab:
.max_features(20000)
- Filter rarer terms:
.min_df(5)
- Filter extremely frequent terms (e.g., near stopwords):
.max_df(0.85)
- Skip stopword removal:
Tokenizer::new().with_stopwords(false)
- Keep short tokens:
.with_min_length(1)
- Stratified split:
- Replace
train_test_splitwithstratified_train_test_split(requires integer labels).
- Replace
- “Failed to open CSV file”
- Ensure the path matches your file (default is
src/result.csv) and that it has headers.
- Ensure the path matches your file (default is
- “Failed to parse label”
- Check labels are numeric in { -1, 0, +1 } with no stray spaces.
- Empty or tiny vocab
- Try reducing
min_df, increasingmax_df, and/or adjustingngram_range.
- Try reducing
- Out‑of‑vocabulary at inference
- New tokens simply map to zeros. This is expected with TF‑IDF; consider retraining the vectorizer when domain shifts.
- Memory/Speed
- Reduce
max_features, use unigrams only, and/or enable release mode (--release).
- Reduce
There is a minimal example in src/NN/train.rs that demonstrates integrating with the saved artifacts. You can call it from your own binary like:
// src/bin/train_example.rs (you can create this file)
use anyhow::Result;
use sentiment_analysis::NN::train::train_model;
fn main() -> Result<()> {
// Sizes: [input_dim (auto‑aligned), hidden1, hidden2, output=3]
let sizes = vec![0, 128, 64, 3];
train_model("data", sizes, /*epochs=*/10, /*lr=*/0.01)?;
Ok(())
}Run it:
cargo run --bin train_exampleNotes:
- This is intentionally minimal (MSE, simple update). For production use, prefer a mature framework or extend this to include batching, better losses, and optimizers.
- Deterministic vocabulary: sorting post‑filtering ensures consistent feature indices across runs and machines.
- Smoothed IDF: avoids division by zero and stabilizes weights on small corpora.
- Separate serialization for vectorizer, matrices, and labels: allows flexible loading and updates.
- Builder pattern for TF‑IDF: encourages explicit choice of preprocessing settings.
- Add model training CLI and metrics.
- Add cross‑validation or a validation split and perform TF‑IDF hyperparameter sweeps.
- Integrate probability calibration and downstream logic.
- Add confusion matrix and per‑class metrics in an evaluation binary.
- Add a config file for Neuro‑Fuzzy parameters if you want to switch settings without recompiling.
The hybrid combines the NN output with interpretable cues and Sugeno‑style rules:
- Memberships (6‑dim):
- μ_neg, μ_neu, μ_pos — NN softmax probabilities (with temperature) for each class.
- conf_high — a margin‑based confidence cue (top‑1 minus top‑2).
- ambig_high — an entropy‑based ambiguity cue.
- pos_over_neg_high — a direct logit gap (z_pos − z_neg) cue.
- Rules: product t‑norm over selected memberships; consequents are constants; output is weighted average.
- Label mapping: score in (−∞, ∞) mapped to {−1, 0, +1} using a threshold τ.
Centralized configuration lives in src/fuzzy/params.rs:
pub const NF_TEMPERATURE: f32 = 0.8;
pub const NF_MARGIN_CENTER: f32 = 0.3;
pub const NF_MARGIN_SLOPE: f32 = 5.0;
pub const NF_ENTROPY_CENTER: f32 = 1.0;
pub const NF_ENTROPY_SLOPE: f32 = 6.0;
pub const NF_GAP_CENTER: f32 = 0.2;
pub const NF_GAP_SLOPE: f32 = 4.0;
pub const NF_LABEL_THRESHOLD: f32 = 0.33;
pub const NF_CLASS_ORDER: [usize; 3] = [0, 1, 2];Edit these constants (and/or default_rules()) to change behavior globally for both demo and evaluator.
Example current results on the included dataset (for reference; your results may vary):
Results on test set (N = 707):
NN‑only accuracy: 60.11% (425 / 707)
Neuro‑Fuzzy accuracy: 58.84% (416 / 707)
- Q: Can I use a different tokenizer?
- A: Yes. Swap in your own tokenizer as long as you pass
Vec<String>tokens into the vectorizer.
- A: Yes. Swap in your own tokenizer as long as you pass
- Q: Can I keep the sparse representation?
- A: The vectorizer returns sparse maps; you can process sparse directly if your model supports it.
- Q: How do I add more labels/classes?
- A: Adjust your dataset and model accordingly. The TF‑IDF pipeline is label‑agnostic.
This repository is provided as‑is for educational and experimental purposes. Add your preferred license if you plan to distribute.