Fruit Classification with ViT Features & sklearn
ViT feature extraction for fruit classification: use google/vit-large-patch16-224-in21k CLS embeddings with PyTorch and scikit-learn to reach 96.27% accuracy.
Fruit Classification with ViT Features & sklearn
A verified technical deep-dive into azario0/fruit-classification-with-vit-features — a Python notebook that classifies 9 fruits without fine-tuning a deep network, by using Hugging Face
google/vit-large-patch16-224-in21kas a frozen feature extractor and training lightweight scikit-learn classifiers on the CLS embeddings.
If you searched for “ViT feature extraction Python,” “Vision Transformer fruit classification,” “Hugging Face ViT embeddings with scikit-learn,” or “how to use ViT without fine-tuning PyTorch,” this guide breaks down a working, reproducible implementation cell-by-cell, with every claim verified against the actual repository code.
TL;DR — What This Project Does
Instead of fine-tuning a 300M-parameter Vision Transformer, the notebook at notebook.ipynb freezes the pretrained ViT and treats it as a universal image encoder:
- Loads 668 fruit images + YOLO-format labels from the Fruit Detection YOLO dataset (534 train / 134 val)
- Uses
AutoImageProcessorandViTModelfrom Hugging Face Transformers to convert every image into a 1024-dimensional CLS vector - Trains four classical classifiers from scikit-learn on those embeddings — LogisticRegression hits 96.27% validation accuracy without a single gradient step in the ViT
- Evaluates with
accuracy_scoreandclassification_reportper class
It’s the textbook transfer learning via feature extraction pattern, implemented in 6 cells and ~100 lines of Python.
Programming Languages Used
| Language | Role | Where |
|---|---|---|
| Python 3.11 | All logic: data loading, PyTorch DataLoader, Hugging Face ViT, scikit-learn training | notebook.ipynb — 6 cells, documented as 3.11.11 in notebook metadata |
| Jupyter Notebook (IPython) | Interactive execution format (.ipynb JSON) — no separate .py modules | notebook.ipynb |
| Markdown | Documentation | README.md |
There is no JavaScript, no web framework, no database, no Docker, and no separate Python package — the entire project is one notebook plus a README. The LICENSE is MIT.
Technology Stack at a Glance
| Layer | Technology | Purpose | Verified In |
|---|---|---|---|
| Deep Learning | PyTorch + torch.utils.data | Dataset, DataLoader, torch.no_grad(), device handling (cuda/cpu) | notebook.ipynb Cell 0, Cell 2 |
| Vision Backbone | Hugging Face Transformers google/vit-large-patch16-224-in21k | ViTModel (feature extractor) + AutoImageProcessor (resize/normalize) | notebook.ipynb Cell 1 — see workflow in README.md |
| Classical ML | scikit-learn | LogisticRegression, SVC, RandomForestClassifier, GradientBoostingClassifier, accuracy_score, classification_report | notebook.ipynb Cell 0, Cell 3 |
| Image I/O | Pillow (PIL) | Image.open(...).convert("RGB") | notebook.ipynb Cell 2 |
| Data & Progress | pandas, numpy, glob, tqdm | DataFrame for image-label pairs, array ops, file discovery, progress bars | notebook.ipynb Cell 0, Cell 1 |
| Dataset | Kaggle Fruit Detection YOLO (9 classes) | apple, avocado, banana, guava, kiwi, mango, orange, peach, pineapple | README.md, notebook.ipynb Cell 0 CLASS_NAMES |
Installation is pip install torch transformers scikit-learn pandas pillow tqdm notebook as noted in README.md (no requirements.txt is committed, though the README references it).
Project Structure
Verified with ls -R:
fruit-classification-with-vit-features/
├── notebook.ipynb # 6 cells, ~380 lines — the entire pipeline
├── README.md # 93 lines: overview, dataset, results table, run instructions
└── LICENSE # MIT
The notebook metadata confirms a Kaggle environment with GPU enabled ("isGpuEnabled": true, "accelerator": "gpu") and language: python 3.11.11 — but the code auto-falls back to cpu via DEVICE = "cuda" if torch.cuda.is_available() else "cpu" in notebook.ipynb Cell 0.
Dataset Explained — YOLO Format to Classification
The project uses the Fruit Detection YOLO dataset, originally for detection (bounding boxes), repurposed here for classification by reading only the class ID.
Expected layout per README.md:
data/fruit-detection-dataset/fruit-detection-dataset/
├── images/train/ (534 images)
├── images/val/ (134 images)
├── labels/train/ (534 .txt files, YOLO format)
└── labels/val/ (134 .txt files)
Each YOLO label file (e.g., apple_12.txt) contains one line like 3 0.5 0.5 0.8 0.9 where the first integer is the class_id (0=apple … 8=pineapple per CLASS_NAMES at notebook.ipynb Cell 0). The notebook ignores the bounding box coordinates — a clever shortcut that avoids re-annotating for classification.
Architecture Overview — How the Pieces Fit Together
YOLO .txt labels + JPG images
│ build_df() [Cell 1]
▼
DataFrames: train_df (534) / val_df (134) — {image:path, label:int}
│ FruitClsDataset + AutoImageProcessor (vit-large-patch16-224-in21k)
▼
DataLoader (batch=16, collate_fn) → pixel_values [16,3,224,224]
│ ViTModel.from_pretrained(...).eval().to(DEVICE) — frozen
▼
last_hidden_state[:,0] — CLS token [16,1024]
│ torch.no_grad() + numpy stacking
▼
X_train [534,1024], y_train / X_val [134,1024], y_val
│ LogisticRegression / SVC / RandomForest / GradientBoosting (fit on X_train)
▼
accuracy_score + classification_report on X_val → res_df sorted by val_acc
▼ 96.27% LogisticRegression → 94.03% SVM/RF → 92.54% GB
- No fine-tuning: The ViT is
.eval()andtorch.no_grad()— no gradients, no optimizer, just a forward pass. - Transfer learning: ImageNet-21k pretraining (14M images, 21k classes) provides universal visual features; the 9 fruit classes are learned by a linear model on top.
- CLI-friendly: The frozen backbone is instantiated inside
extract_embeddings()atnotebook.ipynbCell 2, loadinggoogle/vit-large-patch16-224-in21k(1.22GBmodel.safetensors— see Cell 4 output1.22G).
Deep Dive 1: Configuration & Imports — Cell 0
Imports — notebook.ipynb Cell 0
import os, glob, numpy as np, pandas as pd
from PIL import Image
from tqdm.auto import tqdm
import torch
from torch.utils.data import Dataset, DataLoader
from transformers import ViTModel, AutoImageProcessor
from sklearn.linear_model import LogisticRegression
from sklearn.svm import SVC
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from sklearn.metrics import accuracy_score, classification_report
ViTModelis intentionally used — notViTForImageClassification.ViTModelexposeslast_hidden_state, giving the raw CLS embedding;ViTForImageClassificationwould include its own ImageNet head.train_test_splitis imported but unused — the dataset already has a train/val split viaimages/trainvsimages/val.
Config — notebook.ipynb Cell 0
DATA_DIR = "/kaggle/input/fruit-detection-yolo/fruit-detection-dataset/fruit-detection-dataset"
TRAIN_IMG_DIR = os.path.join(DATA_DIR, "images/train")
TRAIN_LABEL_DIR = os.path.join(DATA_DIR, "labels/train")
VAL_IMG_DIR = os.path.join(DATA_DIR, "images/val")
VAL_LABEL_DIR = os.path.join(DATA_DIR, "labels/val")
BATCH_SIZE = 16
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
CLASS_NAMES = ["apple","avocado","banana","guava","kiwi","mango","orange","peach","pineapple"]
- DATA_DIR: Kaggle-specific absolute path — must be changed to
data/fruit-detection-dataset/...for local runs, as noted inREADME.mdStep 4. - BATCH_SIZE 16: Fits ViT-Large (≈307M params) in 16GB GPU RAM with
224×224images; larger batches would OOM on Kaggle T4/P100. - DEVICE: Auto-selects
cudaon Kaggle GPU, elsecpulocally. - CLASS_NAMES: 9 strings, order maps to YOLO class IDs
0-8— used astarget_namesinclassification_reportatnotebook.ipynbCell 4.
Deep Dive 2: Building Classification DataFrames — build_df() Cell 1
build_df(img_dir, label_dir) — notebook.ipynb Cell 1
def build_df(img_dir, label_dir):
rows = []
for img_path in tqdm(glob.glob(os.path.join(img_dir, "*"))):
fname = os.path.basename(img_path)
lbl_path = os.path.join(label_dir, os.path.splitext(fname)[0] + ".txt")
if not os.path.exists(lbl_path):
continue
with open(lbl_path, "r") as f:
line = f.readline().strip().split()
if len(line)==0: continue
class_id = int(line[0])
rows.append({"image": img_path, "label": class_id})
return pd.DataFrame(rows)
train_df = build_df(TRAIN_IMG_DIR, TRAIN_LABEL_DIR) # 534 rows
val_df = build_df(VAL_IMG_DIR, VAL_LABEL_DIR) # 134 rows
- Glob + tqdm: Iterates over every
images/train/*JPG, shows progress bar0/534(verified in Cell 1 output). - YOLO parsing: Derives
lbl_pathby swapping extension to.txt, reads only the first line andsplit()— robust even if the file has multiple objects (takes first).class_id = int(line[0])ignores bbox coordsline[1:]. - Safe skips: Continues if label file missing or empty — handles corrupted pairs gracefully.
- Output:
DataFramewith columnsimage(absolute path) andlabel(int 0-8). Verified output:Train samples: 534, Val samples: 134(Cell 1 execution result).
Processor Init — notebook.ipynb Cell 1 bottom line
processor = AutoImageProcessor.from_pretrained("google/vit-large-patch16-224-in21k")
- Downloads the official preprocessor: resizes to
224×224, normalizes with ImageNet-21k mean/std, returnspixel_valuesasfloat32[B,3,224,224]. UsingAutoImageProcessor(notViTFeatureExtractor) is the current Hugging Face best practice.
Deep Dive 3: Dataset, Collation & Feature Extraction — Cell 2
FruitClsDataset — notebook.ipynb Cell 2
class FruitClsDataset(Dataset):
def __init__(self, df, processor):
self.df = df.reset_index(drop=True)
self.processor = processor
def __len__(self):
return len(self.df)
def __getitem__(self, idx):
row = self.df.loc[idx]
img = Image.open(row.image).convert("RGB")
pv = self.processor(images=img, return_tensors="pt")["pixel_values"].squeeze(0)
label = row.label
return pv, label
- Reset index:
reset_index(drop=True)ensuresloc[idx]works even if the DataFrame was sliced. - RGB conversion:
.convert("RGB")handles grayscale/alpha edge cases — ViT expects 3 channels. - Processor trick:
return_tensors="pt"gives shape[1,3,224,224], thensqueeze(0)→[3,224,224]— necessary because the processor is batched-unaware inside__getitem__; batching is done later incollate_fn.
collate_fn — notebook.ipynb Cell 2
def collate_fn(batch):
imgs = torch.stack([b[0] for b in batch], dim=0) # [B,3,224,224]
labels = torch.tensor([b[1] for b in batch], dtype=torch.long)
return imgs, labels
- Stacks individual tensors into a batch for efficient GPU MatMul.
dtype=torch.longmatches scikit-learn andCrossEntropyLossconventions (though we never compute loss here).
extract_embeddings(df) — notebook.ipynb Cell 2 — The Core Innovation
def extract_embeddings(df):
ds = FruitClsDataset(df, processor)
dl = DataLoader(ds, batch_size=BATCH_SIZE, shuffle=False, collate_fn=collate_fn)
model = ViTModel.from_pretrained("google/vit-large-patch16-224-in21k").eval().to(DEVICE)
all_embs, all_labels = [], []
with torch.no_grad():
for imgs, labels in tqdm(dl):
imgs = imgs.to(DEVICE)
out = model(imgs).last_hidden_state[:,0] # take CLS token
all_embs.append(out.cpu().numpy())
all_labels.append(labels.numpy())
embs = np.concatenate(all_embs, axis=0) # [N,1024]
labs = np.concatenate(all_labels, axis=0)
return embs, labs
- Frozen ViT:
.eval()disables dropout/stochastic depth;torch.no_grad()disables gradient graph — pure inference, 10× faster and half the memory. - Model choice:
google/vit-large-patch16-224-in21k— ViT-Large: 24 transformer blocks, hidden size 1024, 16×16 patches (so224/16=14→14×14=196patches + 1 CLS = 197 tokens).in21kmeans pre-trained on ImageNet-21k (14M images), not the smaller 1k — richer features, as noted inREADME.md. - CLS token:
last_hidden_state[:,0]at lineout = model(imgs).last_hidden_state[:,0]— indexes the first token (index 0) of the sequence. In ViT, this learnable[CLS]embedding aggregates global image information via self-attention across all patches, analogous to BERT’s CLS. Shape per batch:[B,197,1024]→[B,1024]after slicing. - DataLoader:
tqdm(dl)shows0/34batches for train (ceil(534/16)=34) and0/9for val (ceil(134/16)=9), verified in Cell 3 outputs. - Re-instantiation note: The model is loaded inside the function, so calling
extract_embeddings(train_df)thenextract_embeddings(val_df)innotebook.ipynbCell 3 loads the 1.22GB ViT twice (see Cell 4 outputmodel.safetensors: 0%| … /1.22G). This is wasteful but ensures the second call isn’t affected by device moves — an easy optimization is to pass the model as an argument. - Output:
embsisnp.ndarray [N,1024]float32, ready for scikit-learn;labsis[N]int64.
Deep Dive 4: Classifier Training & Evaluation — Cells 3-4
Classifier Zoo — notebook.ipynb Cell 3
classifiers = {
"LogisticRegression": LogisticRegression(max_iter=200, n_jobs=-1),
"SVM (RBF)": SVC(gamma="scale"),
"RandomForest": RandomForestClassifier(n_estimators=100, n_jobs=-1),
"GradientBoosting": GradientBoostingClassifier(n_estimators=100),
}
- Hyperparameters: All defaults except
max_iter=200for Logistic Regression (increased from default100to ensure convergence on 1024-dim data) and explicitn_jobs=-1for parallelism on LR/RF.gamma="scale"is sklearn’s SVC default (1 / (n_features * X.var())). - No tuning: No grid search — the point is that even untuned classics perform well on ViT embeddings, as emphasized in
README.md.
Training Loop — notebook.ipynb Cell 4
results = []
for name, clf in classifiers.items():
print(f"\nTraining {name}…")
clf.fit(X_train, y_train)
preds = clf.predict(X_val)
acc = accuracy_score(y_val, preds)
print(f"→ {name} validation accuracy: {acc:.4f}")
print(classification_report(y_val, preds, target_names=CLASS_NAMES, zero_division=0))
results.append({"model": name, "val_acc": acc})
res_df = pd.DataFrame(results).sort_values("val_acc", ascending=False)
print(res_df)
- Fit on embeddings, not images:
clf.fit(X_train, y_train)whereX_trainis[534,1024]— each classifier sees only 1024 numbers per image, not 150k pixels. - Metrics:
accuracy_score(overall) +classification_report(per-class precision/recall/F1, macro avg). Verified outputs in Cell 4 show LogisticRegression per-class F1 0.94-1.00, withapplerecall 0.91 /mango0.91 being the weakest,avocado/guava/kiwiperfect 1.00.
Results Analysis — Why Logistic Regression Wins at 96.27%
Verified table from both README.md and Cell 4 outputs:
| Model | Validation Accuracy | vs README | Interpretation |
|---|---|---|---|
| LogisticRegression | 0.9627 | ✅ 0.9627 | Linear separator is enough — ViT space is already well-clustered |
| SVM (RBF) | 0.9403 | ✅ 0.9403 | RBF kernel helps but overfits slightly vs linear |
| RandomForest | 0.9403 | ✅ 0.9403 | Tree ensembles handle non-linearities but lose on high-dim sparse embeddings |
| GradientBoosting | 0.9254 | ✅ 0.9254 | Boosting underperforms with 100 trees on 1024 dims; needs tuning |
Insight: The google/vit-large-patch16-224-in21k CLS space is linearly separable for fruits. This is a classic finding: large pretrained ViTs do the hard representation learning, so downstream tasks need only a simple linear probe. The README’s comment in README.md — “even a simple linear model can perform exceptionally well on top of powerful pre-computed features” — is directly backed by the 96.27% vs 92-94% gap.
How to Run — Verified Step-by-Step
From README.md and notebook config:
git clone https://github.com/azario0/fruit-classification-with-vit-features.git
cd fruit-classification-with-vit-features
# venv recommended
python -m venv venv && source venv/bin/activate # Windows: venv\Scripts\activate
# No committed requirements.txt — install manually (verified list from README):
pip install torch transformers scikit-learn pandas pillow tqdm notebook
# Or if you create one:
# echo -e "torch\ntransformers\nscikit-learn\npandas\npillow\ntqdm\nnotebook" > requirements.txt
# pip install -r requirements.txt
# 1. Download Kaggle dataset: https://www.kaggle.com/datasets/ayush2051/fruit-detection-yolo
# 2. Unzip to data/fruit-detection-dataset/fruit-detection-dataset/ to match:
# data/fruit-detection-dataset/fruit-detection-dataset/images/{train,val}/
# data/fruit-detection-dataset/fruit-detection-dataset/labels/{train,val}/
# 3. Edit notebook.ipynb Cell 0 if needed:
# DATA_DIR = "data/fruit-detection-dataset/fruit-detection-dataset"
# 4. Launch
jupyter notebook
# Run cells 0 → 4 sequentially (Cell 1 + Cell 3 show tqdm progress)
Verify success: After Run All, Cell 4 should end with:
=== Summary ===
model val_acc
0 LogisticRegression 0.962687
1 SVM (RBF) 0.940299
...
and the classification_report tables matching the README.
Customization & Extensions
- Swap the backbone: Change
"google/vit-large-patch16-224-in21k"to"google/vit-base-patch16-224"innotebook.ipynbCell 1 and Cell 2 — ViT-Base is 86M params / 768-dim, faster for CPU. Or try"google/vit-huge-patch14-224-in21k"(632M) for higher accuracy. - Try other CLS alternatives: Replace
last_hidden_state[:,0]at Cell 2 withlast_hidden_state.mean(dim=1)(pooled patches) or the Hugging Facepooler_output— compare which embedding separates fruits better. - Add more classifiers: Append to
classifiersdict in Cell 3 — e.g.,KNeighborsClassifier(n_neighbors=5),MLPClassifier(hidden_layer_sizes=(512,)). - Augment data: Add
RandomHorizontalFlip,ColorJitterinFruitClsDataset.__getitem__viatorchvision.transformsbefore the processor — useful if you split train/val differently (notetrain_test_splitis already imported but unused in Cell 0). - Fine-tune instead: Replace frozen
ViTModelwithViTForImageClassification+Trainerand unfreeze the last 2 blocks — but expect 10-20× training time vs the 30-second feature extraction shown here.
Known Limitations
- Loads ViT twice:
extract_embeddings()instantiatesViTModelper call in Cell 2 — the 1.22GB download happens twice when extracting train then val. Cache the model globally to save time. - Ignores multiple objects:
readline().split()at Cell 1 reads only the first detection per image — if an image hasapple+banana, onlyappleis used. The YOLO dataset happens to be largely single-fruit per image, so this is low-risk here. - No test set: Only train/val — add
images/testhandling for true holdout evaluation. - No saved artifacts: Embeddings
X_train/X_valand trained classifiers are not persisted (np.save/joblib.dump) — re-running the extract step is required each time. - Hardcoded Kaggle paths:
DATA_DIRat Cell 0 is Kaggle-specific; remove the absolute prefix for local clones.
These are normal for a Kaggle-style tutorial notebook and are great first PRs.
Conclusion — Why This Repo Is a Great ViT + sklearn Starter
If you want to learn Vision Transformer feature extraction without GPU fine-tuning, this notebook is a minimal, verified reference:
- ViT done right:
AutoImageProcessor+ViTModel+last_hidden_state[:,0]is the canonical frozen-embedding recipe — 3 lines replace 300 lines of manual patch embedding. - PyTorch done simply: Custom
Dataset,collate_fn,DataLoader(batch=16),torch.no_grad()— all best practices in one cell. - sklearn done practically: Four classifiers on 1024-dim embeddings show that representation matters more than model complexity — Logistic Regression at 96.27% proves it.
Clone it, point DATA_DIR to your data/ folder per README.md, run Cell 0 → Cell 4, and watch the ViT turn fruit photos into vectors that a linear model can separate almost perfectly. From there, fork in a new backbone, a k-NN, or a Gradio demo — the pipeline is ready for it.
Links: notebook.ipynb · README.md · LICENSE · Fruit Detection YOLO (Kaggle) · google/vit-large-patch16-224-in21k (HF)
Verified against commit main on 2026-09-02. All file paths, cell contents, hyperparameters, sample counts (534/134), and accuracy figures (0.9627) checked against the cloned repository and executed notebook outputs.
Written by
Benmalek Zohir
Founder, AI Engineer & Full Stack Developer
Benmalek Zohir is an AI Engineer, Full Stack Developer, and technology enthusiast focused on artificial intelligence, software development, and emerging technologies. He is the founder of SoftwareJournal.blog, where he shares practical insights, software discoveries, AI tools, and the latest developments in technology.