use crate::base::SelectorMixin;
use scirs2_core::ndarray::{Array1, Array2, ArrayView1, Axis};
use sklears_core::{
error::{validate, Result as SklResult, SklearsError},
traits::{Estimator, Fit, Trained, Transform, Untrained},
types::Float,
};
use std::collections::HashMap;
use std::marker::PhantomData;
#[derive(Debug, Clone)]
pub struct TextFeatureSelector<State = Untrained> {
min_df: f64,
max_df: f64,
max_features: Option<usize>,
ngram_range: (usize, usize),
include_pos: bool,
include_syntax: bool,
state: PhantomData<State>,
vocabulary_: Option<HashMap<String, usize>>,
idf_scores_: Option<Array1<Float>>,
selected_features_: Option<Vec<usize>>,
feature_names_: Option<Vec<String>>,
}
impl TextFeatureSelector<Untrained> {
pub fn new() -> Self {
Self {
min_df: 0.01,
max_df: 0.95,
max_features: Some(1000),
ngram_range: (1, 1),
include_pos: false,
include_syntax: false,
state: PhantomData,
vocabulary_: None,
idf_scores_: None,
selected_features_: None,
feature_names_: None,
}
}
pub fn min_df(mut self, min_df: f64) -> Self {
self.min_df = min_df;
self
}
pub fn max_df(mut self, max_df: f64) -> Self {
self.max_df = max_df;
self
}
pub fn max_features(mut self, max_features: Option<usize>) -> Self {
self.max_features = max_features;
self
}
pub fn ngram_range(mut self, ngram_range: (usize, usize)) -> Self {
self.ngram_range = ngram_range;
self
}
pub fn include_pos(mut self, include_pos: bool) -> Self {
self.include_pos = include_pos;
self
}
pub fn include_syntax(mut self, include_syntax: bool) -> Self {
self.include_syntax = include_syntax;
self
}
}
impl Default for TextFeatureSelector<Untrained> {
fn default() -> Self {
Self::new()
}
}
impl Estimator for TextFeatureSelector<Untrained> {
type Config = ();
type Error = SklearsError;
type Float = f64;
fn config(&self) -> &Self::Config {
&()
}
}
impl Fit<Array2<Float>, Array1<Float>> for TextFeatureSelector<Untrained> {
type Fitted = TextFeatureSelector<Trained>;
fn fit(self, x: &Array2<Float>, y: &Array1<Float>) -> SklResult<Self::Fitted> {
validate::check_consistent_length(x, y)?;
let (n_documents, n_features) = x.dim();
let mut document_frequencies = Array1::zeros(n_features);
for j in 0..n_features {
let mut df = 0.0;
for i in 0..n_documents {
if x[[i, j]] > 0.0 {
df += 1.0;
}
}
document_frequencies[j] = df / n_documents as f64;
}
let mut valid_features = Vec::new();
for (j, &df) in document_frequencies.iter().enumerate() {
if df >= self.min_df && df <= self.max_df {
valid_features.push(j);
}
}
if valid_features.is_empty() {
return Err(SklearsError::InvalidInput(
"No features pass the document frequency filters".to_string(),
));
}
let mut idf_scores = Array1::zeros(valid_features.len());
for (idx, &j) in valid_features.iter().enumerate() {
let df = document_frequencies[j];
idf_scores[idx] = (n_documents as f64 / (1.0 + df * n_documents as f64)).ln();
}
let mut chi2_scores = Array1::zeros(valid_features.len());
for (idx, &j) in valid_features.iter().enumerate() {
let feature_col = x.column(j);
chi2_scores[idx] = compute_chi2_score(&feature_col, y);
}
let mut combined_scores = Array1::zeros(valid_features.len());
for i in 0..combined_scores.len() {
combined_scores[i] = 0.6 * idf_scores[i] + 0.4 * chi2_scores[i];
}
let mut scored_features: Vec<(usize, Float)> = combined_scores
.indexed_iter()
.map(|(i, &score)| (valid_features[i], score))
.collect();
scored_features.sort_by(|a, b| b.1.partial_cmp(&a.1).expect("operation should succeed"));
let selected_features = if let Some(max_feat) = self.max_features {
scored_features
.iter()
.take(max_feat.min(scored_features.len()))
.map(|(i, _)| *i)
.collect::<Vec<_>>()
} else {
scored_features.iter().map(|(i, _)| *i).collect()
};
let feature_names: Vec<String> = selected_features
.iter()
.map(|&i| format!("term_{}", i))
.collect();
let vocabulary: HashMap<String, usize> = feature_names
.iter()
.enumerate()
.map(|(i, name)| (name.clone(), i))
.collect();
Ok(TextFeatureSelector {
min_df: self.min_df,
max_df: self.max_df,
max_features: self.max_features,
ngram_range: self.ngram_range,
include_pos: self.include_pos,
include_syntax: self.include_syntax,
state: PhantomData,
vocabulary_: Some(vocabulary),
idf_scores_: Some(idf_scores),
selected_features_: Some(selected_features),
feature_names_: Some(feature_names),
})
}
}
impl Transform<Array2<Float>> for TextFeatureSelector<Trained> {
fn transform(&self, x: &Array2<Float>) -> SklResult<Array2<Float>> {
let selected_features = self
.selected_features_
.as_ref()
.expect("operation should succeed");
if selected_features.is_empty() {
return Err(SklearsError::InvalidInput(
"No features were selected".to_string(),
));
}
let selected_indices: Vec<usize> = selected_features.to_vec();
Ok(x.select(Axis(1), &selected_indices))
}
}
impl SelectorMixin for TextFeatureSelector<Trained> {
fn get_support(&self) -> SklResult<Array1<bool>> {
let selected_features = self
.selected_features_
.as_ref()
.expect("operation should succeed");
let n_features = self
.idf_scores_
.as_ref()
.expect("operation should succeed")
.len()
+ selected_features.iter().max().unwrap_or(&0)
+ 1;
let mut support = Array1::from_elem(n_features, false);
for &idx in selected_features {
if idx < n_features {
support[idx] = true;
}
}
Ok(support)
}
fn transform_features(&self, indices: &[usize]) -> SklResult<Vec<usize>> {
let selected_features = self
.selected_features_
.as_ref()
.expect("operation should succeed");
Ok(indices
.iter()
.filter_map(|&idx| selected_features.iter().position(|&f| f == idx))
.collect())
}
}
impl TextFeatureSelector<Trained> {
pub fn vocabulary(&self) -> &HashMap<String, usize> {
self.vocabulary_.as_ref().expect("operation should succeed")
}
pub fn idf_scores(&self) -> &Array1<Float> {
self.idf_scores_.as_ref().expect("operation should succeed")
}
pub fn feature_names(&self) -> &[String] {
self.feature_names_
.as_ref()
.expect("operation should succeed")
}
pub fn selected_features(&self) -> &[usize] {
self.selected_features_
.as_ref()
.expect("operation should succeed")
}
pub fn n_features_selected(&self) -> usize {
self.selected_features_
.as_ref()
.expect("operation should succeed")
.len()
}
pub fn feature_summary(&self) -> Vec<(usize, &str, Float)> {
let indices = self.selected_features();
let names = self.feature_names();
let scores = self.idf_scores();
let mut summary: Vec<(usize, &str, Float)> = indices
.iter()
.zip(names.iter())
.zip(scores.iter())
.map(|((&idx, name), &score)| (idx, name.as_str(), score))
.collect();
summary.sort_by_key(|&(idx, _, _)| idx);
summary
}
}
fn compute_chi2_score(feature: &ArrayView1<Float>, target: &Array1<Float>) -> Float {
let feature_mean = feature.mean().unwrap_or(0.0);
let target_mean = target.mean().unwrap_or(0.0);
let mut chi2 = 0.0;
let n = feature.len();
for i in 0..n {
let f_i = if feature[i] > feature_mean { 1.0 } else { 0.0 };
let t_i = if target[i] > target_mean { 1.0 } else { 0.0 };
let observed = f_i * t_i;
let expected = (feature.sum() / n as Float) * (target.sum() / n as Float);
if expected > 0.0 {
chi2 += (observed - expected).powi(2) / expected;
}
}
chi2
}
pub fn compute_document_frequency(term_vector: &ArrayView1<Float>) -> Float {
let n_documents = term_vector.len() as Float;
let documents_with_term = term_vector.iter().filter(|&&count| count > 0.0).count() as Float;
documents_with_term / n_documents
}
pub fn compute_tfidf_score(
term_frequency: Float,
document_frequency: Float,
n_documents: usize,
) -> Float {
let idf = (n_documents as Float / (1.0 + document_frequency * n_documents as Float)).ln();
term_frequency * idf
}
pub fn create_text_feature_selector() -> TextFeatureSelector<Untrained> {
TextFeatureSelector::new()
}
pub fn create_short_text_selector() -> TextFeatureSelector<Untrained> {
TextFeatureSelector::new()
.min_df(0.005) .max_df(0.8) .max_features(Some(500))
.ngram_range((1, 2)) }
pub fn create_long_text_selector() -> TextFeatureSelector<Untrained> {
TextFeatureSelector::new()
.min_df(0.02) .max_df(0.95) .max_features(Some(2000))
.ngram_range((1, 3)) }
pub fn create_multilingual_selector() -> TextFeatureSelector<Untrained> {
TextFeatureSelector::new()
.min_df(0.01)
.max_df(0.9) .max_features(Some(1500))
.include_pos(true) }
pub fn create_classification_selector() -> TextFeatureSelector<Untrained> {
TextFeatureSelector::new()
.min_df(0.02)
.max_df(0.9)
.max_features(Some(1000))
.ngram_range((1, 2))
}
#[allow(non_snake_case)]
#[cfg(test)]
mod tests {
use super::*;
use scirs2_core::ndarray::{array, Array2};
#[test]
fn test_document_frequency_computation() {
let term_vector = array![1.0, 0.0, 2.0, 0.0, 1.0]; let df = compute_document_frequency(&term_vector.view());
assert!((df - 0.6).abs() < 1e-10);
}
#[test]
fn test_tfidf_computation() {
let tf = 3.0;
let df = 0.5; let n_docs = 100;
let tfidf = compute_tfidf_score(tf, df, n_docs);
assert!(tfidf > 0.0);
}
#[test]
fn test_chi2_score_computation() {
let feature = array![1.0, 2.0, 3.0, 4.0, 5.0];
let target = array![1.0, 1.0, 0.0, 0.0, 0.0];
let chi2 = compute_chi2_score(&feature.view(), &target);
assert!(chi2 >= 0.0);
}
#[test]
fn test_text_feature_selector_basic() {
let selector = TextFeatureSelector::new().min_df(0.1).max_features(Some(2));
let x = Array2::from_shape_vec(
(4, 3),
vec![
1.0, 0.0, 2.0, 0.0, 1.0, 1.0, 1.0, 1.0, 0.0, 2.0, 0.0, 1.0, ],
)
.expect("operation should succeed");
let y = array![1.0, 0.0, 1.0, 0.0];
let fitted = selector.fit(&x, &y).expect("operation should succeed");
assert!(fitted.n_features_selected() <= 2);
let transformed = fitted.transform(&x).expect("operation should succeed");
assert!(transformed.ncols() <= 2);
}
#[test]
fn test_document_frequency_filtering() {
let selector = TextFeatureSelector::new()
.min_df(0.6) .max_df(1.0);
let x = Array2::from_shape_vec(
(5, 3),
vec![
1.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 1.0, 0.0, 0.0, 1.0,
],
)
.expect("operation should succeed");
let y = array![1.0, 0.0, 1.0, 0.0, 1.0];
let fitted = selector.fit(&x, &y).expect("operation should succeed");
assert!(fitted.n_features_selected() <= 2);
}
}