diff --git a/src/main/java/io/github/yasmramos/mindforge/nlp/CountVectorizer.java b/src/main/java/io/github/yasmramos/mindforge/nlp/CountVectorizer.java new file mode 100644 index 0000000..f9ddf99 --- /dev/null +++ b/src/main/java/io/github/yasmramos/mindforge/nlp/CountVectorizer.java @@ -0,0 +1,200 @@ +package io.github.yasmramos.mindforge.nlp; + +import java.io.Serializable; +import java.util.*; +import java.util.regex.Pattern; + +/** + * Convert a collection of text documents to a matrix of token counts. + * Similar to scikit-learn's CountVectorizer. + */ +public class CountVectorizer implements Serializable { + private static final long serialVersionUID = 1L; + + private final boolean lowercase; + private final boolean binary; + private final int minDf; + private final int maxDf; + private final Integer maxFeatures; + private final String tokenPattern; + private final Set stopWords; + private final int[] ngramRange; + + private Map vocabulary; + private String[] featureNames; + private Pattern compiledPattern; + + private CountVectorizer(Builder builder) { + this.lowercase = builder.lowercase; + this.binary = builder.binary; + this.minDf = builder.minDf; + this.maxDf = builder.maxDf; + this.maxFeatures = builder.maxFeatures; + this.tokenPattern = builder.tokenPattern; + this.stopWords = builder.stopWords; + this.ngramRange = builder.ngramRange; + this.compiledPattern = Pattern.compile(tokenPattern); + } + + /** + * Learn the vocabulary from documents. + * @param documents Array of text documents + */ + public void fit(String[] documents) { + Map termDocFreq = new HashMap<>(); + + for (String doc : documents) { + Set docTerms = new HashSet<>(tokenize(doc)); + for (String term : docTerms) { + termDocFreq.merge(term, 1, Integer::sum); + } + } + + // Filter by document frequency + int nDocs = documents.length; + List> validTerms = new ArrayList<>(); + + for (Map.Entry entry : termDocFreq.entrySet()) { + int df = entry.getValue(); + if (df >= minDf && (maxDf <= 0 || df <= maxDf * nDocs)) { + validTerms.add(entry); + } + } + + // Sort by frequency (descending) then alphabetically + validTerms.sort((a, b) -> { + int cmp = b.getValue().compareTo(a.getValue()); + return cmp != 0 ? cmp : a.getKey().compareTo(b.getKey()); + }); + + // Limit features if specified + if (maxFeatures != null && validTerms.size() > maxFeatures) { + validTerms = validTerms.subList(0, maxFeatures); + } + + // Build vocabulary + vocabulary = new HashMap<>(); + featureNames = new String[validTerms.size()]; + + // Sort alphabetically for consistent ordering + validTerms.sort(Comparator.comparing(Map.Entry::getKey)); + + for (int i = 0; i < validTerms.size(); i++) { + String term = validTerms.get(i).getKey(); + vocabulary.put(term, i); + featureNames[i] = term; + } + } + + /** + * Transform documents to a term-document matrix. + * @param documents Array of text documents + * @return Matrix of shape [n_documents, n_features] + */ + public double[][] transform(String[] documents) { + if (vocabulary == null) { + throw new IllegalStateException("Vectorizer must be fitted before transform"); + } + + double[][] result = new double[documents.length][vocabulary.size()]; + + for (int i = 0; i < documents.length; i++) { + List tokens = tokenize(documents[i]); + Map counts = countTokens(tokens); + + for (Map.Entry entry : counts.entrySet()) { + Integer idx = vocabulary.get(entry.getKey()); + if (idx != null) { + result[i][idx] = binary ? 1.0 : entry.getValue(); + } + } + } + + return result; + } + + /** + * Fit and transform in one step. + * @param documents Array of text documents + * @return Matrix of shape [n_documents, n_features] + */ + public double[][] fitTransform(String[] documents) { + fit(documents); + return transform(documents); + } + + private List tokenize(String text) { + if (lowercase) { + text = text.toLowerCase(); + } + + List tokens = new ArrayList<>(); + var matcher = compiledPattern.matcher(text); + List baseTokens = new ArrayList<>(); + + while (matcher.find()) { + String token = matcher.group(); + if (stopWords == null || !stopWords.contains(token)) { + baseTokens.add(token); + } + } + + // Generate n-grams + for (int n = ngramRange[0]; n <= ngramRange[1]; n++) { + for (int i = 0; i <= baseTokens.size() - n; i++) { + StringBuilder ngram = new StringBuilder(); + for (int j = 0; j < n; j++) { + if (j > 0) ngram.append(" "); + ngram.append(baseTokens.get(i + j)); + } + tokens.add(ngram.toString()); + } + } + + return tokens; + } + + private Map countTokens(List tokens) { + Map counts = new HashMap<>(); + for (String token : tokens) { + counts.merge(token, 1, Integer::sum); + } + return counts; + } + + // Getters + public Map getVocabulary() { return vocabulary != null ? new HashMap<>(vocabulary) : null; } + public String[] getFeatureNames() { return featureNames != null ? featureNames.clone() : null; } + public int getVocabularySize() { return vocabulary != null ? vocabulary.size() : 0; } + + public static Set getEnglishStopWords() { + return new HashSet<>(Arrays.asList( + "a", "an", "and", "are", "as", "at", "be", "by", "for", "from", + "has", "he", "in", "is", "it", "its", "of", "on", "that", "the", + "to", "was", "were", "will", "with", "the", "this", "but", "they", + "have", "had", "what", "when", "where", "who", "which", "why", "how" + )); + } + + public static class Builder { + private boolean lowercase = true; + private boolean binary = false; + private int minDf = 1; + private int maxDf = -1; + private Integer maxFeatures = null; + private String tokenPattern = "\\b\\w+\\b"; + private Set stopWords = null; + private int[] ngramRange = {1, 1}; + + public Builder lowercase(boolean lowercase) { this.lowercase = lowercase; return this; } + public Builder binary(boolean binary) { this.binary = binary; return this; } + public Builder minDf(int minDf) { this.minDf = minDf; return this; } + public Builder maxDf(int maxDf) { this.maxDf = maxDf; return this; } + public Builder maxFeatures(int maxFeatures) { this.maxFeatures = maxFeatures; return this; } + public Builder tokenPattern(String pattern) { this.tokenPattern = pattern; return this; } + public Builder stopWords(Set stopWords) { this.stopWords = stopWords; return this; } + public Builder ngramRange(int min, int max) { this.ngramRange = new int[]{min, max}; return this; } + + public CountVectorizer build() { return new CountVectorizer(this); } + } +} diff --git a/src/main/java/io/github/yasmramos/mindforge/nlp/TfidfVectorizer.java b/src/main/java/io/github/yasmramos/mindforge/nlp/TfidfVectorizer.java new file mode 100644 index 0000000..01829c1 --- /dev/null +++ b/src/main/java/io/github/yasmramos/mindforge/nlp/TfidfVectorizer.java @@ -0,0 +1,262 @@ +package io.github.yasmramos.mindforge.nlp; + +import java.io.Serializable; +import java.util.*; +import java.util.regex.Pattern; + +/** + * Convert a collection of text documents to a matrix of TF-IDF features. + * TF-IDF = Term Frequency * Inverse Document Frequency + * Similar to scikit-learn's TfidfVectorizer. + */ +public class TfidfVectorizer implements Serializable { + private static final long serialVersionUID = 1L; + + public enum Norm { L1, L2, NONE } + + private final boolean lowercase; + private final boolean useIdf; + private final boolean smoothIdf; + private final boolean sublinearTf; + private final Norm norm; + private final int minDf; + private final int maxDf; + private final Integer maxFeatures; + private final String tokenPattern; + private final Set stopWords; + private final int[] ngramRange; + + private Map vocabulary; + private String[] featureNames; + private double[] idfWeights; + private Pattern compiledPattern; + private int nDocuments; + + private TfidfVectorizer(Builder builder) { + this.lowercase = builder.lowercase; + this.useIdf = builder.useIdf; + this.smoothIdf = builder.smoothIdf; + this.sublinearTf = builder.sublinearTf; + this.norm = builder.norm; + this.minDf = builder.minDf; + this.maxDf = builder.maxDf; + this.maxFeatures = builder.maxFeatures; + this.tokenPattern = builder.tokenPattern; + this.stopWords = builder.stopWords; + this.ngramRange = builder.ngramRange; + this.compiledPattern = Pattern.compile(tokenPattern); + } + + /** + * Learn vocabulary and IDF weights from documents. + * @param documents Array of text documents + */ + public void fit(String[] documents) { + nDocuments = documents.length; + Map termDocFreq = new HashMap<>(); + Map termFreq = new HashMap<>(); + + for (String doc : documents) { + List tokens = tokenize(doc); + Set docTerms = new HashSet<>(tokens); + + for (String token : tokens) { + termFreq.merge(token, 1, Integer::sum); + } + for (String term : docTerms) { + termDocFreq.merge(term, 1, Integer::sum); + } + } + + // Filter by document frequency + List> validTerms = new ArrayList<>(); + + for (Map.Entry entry : termDocFreq.entrySet()) { + int df = entry.getValue(); + double dfRatio = (double) df / nDocuments; + if (df >= minDf && (maxDf <= 0 || dfRatio <= maxDf)) { + validTerms.add(entry); + } + } + + // Sort by total frequency (descending) then alphabetically + validTerms.sort((a, b) -> { + int freqA = termFreq.getOrDefault(a.getKey(), 0); + int freqB = termFreq.getOrDefault(b.getKey(), 0); + int cmp = Integer.compare(freqB, freqA); + return cmp != 0 ? cmp : a.getKey().compareTo(b.getKey()); + }); + + // Limit features if specified + if (maxFeatures != null && validTerms.size() > maxFeatures) { + validTerms = validTerms.subList(0, maxFeatures); + } + + // Sort alphabetically for consistent ordering + validTerms.sort(Comparator.comparing(Map.Entry::getKey)); + + // Build vocabulary and IDF weights + vocabulary = new HashMap<>(); + featureNames = new String[validTerms.size()]; + idfWeights = new double[validTerms.size()]; + + for (int i = 0; i < validTerms.size(); i++) { + String term = validTerms.get(i).getKey(); + int df = validTerms.get(i).getValue(); + + vocabulary.put(term, i); + featureNames[i] = term; + + if (useIdf) { + if (smoothIdf) { + idfWeights[i] = Math.log((double) (nDocuments + 1) / (df + 1)) + 1; + } else { + idfWeights[i] = Math.log((double) nDocuments / df) + 1; + } + } else { + idfWeights[i] = 1.0; + } + } + } + + /** + * Transform documents to TF-IDF matrix. + * @param documents Array of text documents + * @return Matrix of shape [n_documents, n_features] + */ + public double[][] transform(String[] documents) { + if (vocabulary == null) { + throw new IllegalStateException("Vectorizer must be fitted before transform"); + } + + double[][] result = new double[documents.length][vocabulary.size()]; + + for (int i = 0; i < documents.length; i++) { + List tokens = tokenize(documents[i]); + Map counts = countTokens(tokens); + + // Calculate TF-IDF + for (Map.Entry entry : counts.entrySet()) { + Integer idx = vocabulary.get(entry.getKey()); + if (idx != null) { + double tf = entry.getValue(); + if (sublinearTf) { + tf = 1 + Math.log(tf); + } + result[i][idx] = tf * idfWeights[idx]; + } + } + + // Normalize + if (norm != Norm.NONE) { + normalizeVector(result[i]); + } + } + + return result; + } + + /** + * Fit and transform in one step. + * @param documents Array of text documents + * @return Matrix of shape [n_documents, n_features] + */ + public double[][] fitTransform(String[] documents) { + fit(documents); + return transform(documents); + } + + private void normalizeVector(double[] vector) { + double normValue = 0; + + if (norm == Norm.L2) { + for (double v : vector) { + normValue += v * v; + } + normValue = Math.sqrt(normValue); + } else if (norm == Norm.L1) { + for (double v : vector) { + normValue += Math.abs(v); + } + } + + if (normValue > 0) { + for (int i = 0; i < vector.length; i++) { + vector[i] /= normValue; + } + } + } + + private List tokenize(String text) { + if (lowercase) { + text = text.toLowerCase(); + } + + List tokens = new ArrayList<>(); + var matcher = compiledPattern.matcher(text); + List baseTokens = new ArrayList<>(); + + while (matcher.find()) { + String token = matcher.group(); + if (stopWords == null || !stopWords.contains(token)) { + baseTokens.add(token); + } + } + + // Generate n-grams + for (int n = ngramRange[0]; n <= ngramRange[1]; n++) { + for (int i = 0; i <= baseTokens.size() - n; i++) { + StringBuilder ngram = new StringBuilder(); + for (int j = 0; j < n; j++) { + if (j > 0) ngram.append(" "); + ngram.append(baseTokens.get(i + j)); + } + tokens.add(ngram.toString()); + } + } + + return tokens; + } + + private Map countTokens(List tokens) { + Map counts = new HashMap<>(); + for (String token : tokens) { + counts.merge(token, 1, Integer::sum); + } + return counts; + } + + // Getters + public Map getVocabulary() { return vocabulary != null ? new HashMap<>(vocabulary) : null; } + public String[] getFeatureNames() { return featureNames != null ? featureNames.clone() : null; } + public double[] getIdfWeights() { return idfWeights != null ? idfWeights.clone() : null; } + public int getVocabularySize() { return vocabulary != null ? vocabulary.size() : 0; } + + public static class Builder { + private boolean lowercase = true; + private boolean useIdf = true; + private boolean smoothIdf = true; + private boolean sublinearTf = false; + private Norm norm = Norm.L2; + private int minDf = 1; + private int maxDf = -1; + private Integer maxFeatures = null; + private String tokenPattern = "\\b\\w+\\b"; + private Set stopWords = null; + private int[] ngramRange = {1, 1}; + + public Builder lowercase(boolean lowercase) { this.lowercase = lowercase; return this; } + public Builder useIdf(boolean useIdf) { this.useIdf = useIdf; return this; } + public Builder smoothIdf(boolean smoothIdf) { this.smoothIdf = smoothIdf; return this; } + public Builder sublinearTf(boolean sublinearTf) { this.sublinearTf = sublinearTf; return this; } + public Builder norm(Norm norm) { this.norm = norm; return this; } + public Builder minDf(int minDf) { this.minDf = minDf; return this; } + public Builder maxDf(int maxDf) { this.maxDf = maxDf; return this; } + public Builder maxFeatures(int maxFeatures) { this.maxFeatures = maxFeatures; return this; } + public Builder tokenPattern(String pattern) { this.tokenPattern = pattern; return this; } + public Builder stopWords(Set stopWords) { this.stopWords = stopWords; return this; } + public Builder ngramRange(int min, int max) { this.ngramRange = new int[]{min, max}; return this; } + + public TfidfVectorizer build() { return new TfidfVectorizer(this); } + } +} diff --git a/src/main/java/io/github/yasmramos/mindforge/nlp/Word2Vec.java b/src/main/java/io/github/yasmramos/mindforge/nlp/Word2Vec.java new file mode 100644 index 0000000..e24213b --- /dev/null +++ b/src/main/java/io/github/yasmramos/mindforge/nlp/Word2Vec.java @@ -0,0 +1,290 @@ +package io.github.yasmramos.mindforge.nlp; + +import java.io.Serializable; +import java.util.*; +import java.util.regex.Pattern; + +/** + * Simple Word2Vec implementation using Skip-gram with negative sampling. + * Learns dense vector representations of words from text. + */ +public class Word2Vec implements Serializable { + private static final long serialVersionUID = 1L; + + private final int vectorSize; + private final int windowSize; + private final int minCount; + private final int negativeSamples; + private final double learningRate; + private final int epochs; + private final long seed; + + private Map wordIndex; + private String[] indexToWord; + private double[][] wordVectors; + private double[][] contextVectors; + private int[] wordCounts; + private double[] samplingTable; + + private static final int TABLE_SIZE = 100000000; + + private Word2Vec(Builder builder) { + this.vectorSize = builder.vectorSize; + this.windowSize = builder.windowSize; + this.minCount = builder.minCount; + this.negativeSamples = builder.negativeSamples; + this.learningRate = builder.learningRate; + this.epochs = builder.epochs; + this.seed = builder.seed; + } + + /** + * Train Word2Vec on a corpus of documents. + * @param documents Array of text documents + */ + public void fit(String[] documents) { + Random random = new Random(seed); + + // Tokenize and count words + List> tokenizedDocs = new ArrayList<>(); + Map wordFreq = new HashMap<>(); + + Pattern pattern = Pattern.compile("\\b\\w+\\b"); + for (String doc : documents) { + List tokens = new ArrayList<>(); + var matcher = pattern.matcher(doc.toLowerCase()); + while (matcher.find()) { + String token = matcher.group(); + tokens.add(token); + wordFreq.merge(token, 1, Integer::sum); + } + tokenizedDocs.add(tokens); + } + + // Build vocabulary (filter by minCount) + List> sortedWords = new ArrayList<>(wordFreq.entrySet()); + sortedWords.removeIf(e -> e.getValue() < minCount); + sortedWords.sort((a, b) -> b.getValue().compareTo(a.getValue())); + + int vocabSize = sortedWords.size(); + wordIndex = new HashMap<>(); + indexToWord = new String[vocabSize]; + wordCounts = new int[vocabSize]; + + for (int i = 0; i < vocabSize; i++) { + String word = sortedWords.get(i).getKey(); + wordIndex.put(word, i); + indexToWord[i] = word; + wordCounts[i] = sortedWords.get(i).getValue(); + } + + // Initialize vectors + wordVectors = new double[vocabSize][vectorSize]; + contextVectors = new double[vocabSize][vectorSize]; + + for (int i = 0; i < vocabSize; i++) { + for (int j = 0; j < vectorSize; j++) { + wordVectors[i][j] = (random.nextDouble() - 0.5) / vectorSize; + contextVectors[i][j] = 0; + } + } + + // Build unigram table for negative sampling + buildSamplingTable(); + + // Convert tokenized docs to indices + List indexedDocs = new ArrayList<>(); + for (List tokens : tokenizedDocs) { + List indices = new ArrayList<>(); + for (String token : tokens) { + Integer idx = wordIndex.get(token); + if (idx != null) { + indices.add(idx); + } + } + if (!indices.isEmpty()) { + indexedDocs.add(indices.stream().mapToInt(i -> i).toArray()); + } + } + + // Training + for (int epoch = 0; epoch < epochs; epoch++) { + double currentLr = learningRate * (1 - (double) epoch / epochs); + currentLr = Math.max(currentLr, learningRate * 0.0001); + + for (int[] doc : indexedDocs) { + trainDocument(doc, currentLr, random); + } + } + } + + private void trainDocument(int[] doc, double lr, Random random) { + for (int pos = 0; pos < doc.length; pos++) { + int wordIdx = doc[pos]; + + // Dynamic window + int reducedWindow = random.nextInt(windowSize) + 1; + + for (int j = pos - reducedWindow; j <= pos + reducedWindow; j++) { + if (j < 0 || j >= doc.length || j == pos) continue; + + int contextIdx = doc[j]; + trainPair(wordIdx, contextIdx, true, lr); + + // Negative sampling + for (int k = 0; k < negativeSamples; k++) { + int negIdx = sampleNegative(random); + if (negIdx != contextIdx) { + trainPair(wordIdx, negIdx, false, lr); + } + } + } + } + } + + private void trainPair(int wordIdx, int contextIdx, boolean positive, double lr) { + double[] wordVec = wordVectors[wordIdx]; + double[] contextVec = contextVectors[contextIdx]; + + // Calculate dot product + double dot = 0; + for (int i = 0; i < vectorSize; i++) { + dot += wordVec[i] * contextVec[i]; + } + + // Sigmoid + double sigmoid = 1.0 / (1.0 + Math.exp(-dot)); + double target = positive ? 1.0 : 0.0; + double gradient = lr * (target - sigmoid); + + // Update vectors + for (int i = 0; i < vectorSize; i++) { + double temp = wordVec[i]; + wordVec[i] += gradient * contextVec[i]; + contextVec[i] += gradient * temp; + } + } + + private void buildSamplingTable() { + double power = 0.75; + double trainWordsPow = 0; + + for (int count : wordCounts) { + trainWordsPow += Math.pow(count, power); + } + + samplingTable = new double[wordCounts.length]; + double cumulative = 0; + + for (int i = 0; i < wordCounts.length; i++) { + cumulative += Math.pow(wordCounts[i], power) / trainWordsPow; + samplingTable[i] = cumulative; + } + } + + private int sampleNegative(Random random) { + double r = random.nextDouble(); + int low = 0, high = samplingTable.length - 1; + + while (low < high) { + int mid = (low + high) / 2; + if (samplingTable[mid] < r) { + low = mid + 1; + } else { + high = mid; + } + } + + return low; + } + + /** + * Get the vector representation of a word. + * @param word The word + * @return Vector representation or null if word not in vocabulary + */ + public double[] getVector(String word) { + Integer idx = wordIndex.get(word.toLowerCase()); + if (idx == null) return null; + return wordVectors[idx].clone(); + } + + /** + * Find most similar words to a given word. + * @param word The query word + * @param topN Number of similar words to return + * @return List of (word, similarity) pairs + */ + public List> mostSimilar(String word, int topN) { + double[] queryVec = getVector(word); + if (queryVec == null) return Collections.emptyList(); + + Integer queryIdx = wordIndex.get(word.toLowerCase()); + PriorityQueue> heap = new PriorityQueue<>( + Comparator.comparingDouble(Map.Entry::getValue) + ); + + for (int i = 0; i < wordVectors.length; i++) { + if (i == queryIdx) continue; + + double similarity = cosineSimilarity(queryVec, wordVectors[i]); + heap.offer(new AbstractMap.SimpleEntry<>(indexToWord[i], similarity)); + + if (heap.size() > topN) { + heap.poll(); + } + } + + List> result = new ArrayList<>(heap); + result.sort((a, b) -> Double.compare(b.getValue(), a.getValue())); + return result; + } + + /** + * Calculate similarity between two words. + * @param word1 First word + * @param word2 Second word + * @return Cosine similarity or NaN if either word not found + */ + public double similarity(String word1, String word2) { + double[] vec1 = getVector(word1); + double[] vec2 = getVector(word2); + if (vec1 == null || vec2 == null) return Double.NaN; + return cosineSimilarity(vec1, vec2); + } + + private double cosineSimilarity(double[] a, double[] b) { + double dot = 0, normA = 0, normB = 0; + for (int i = 0; i < a.length; i++) { + dot += a[i] * b[i]; + normA += a[i] * a[i]; + normB += b[i] * b[i]; + } + return dot / (Math.sqrt(normA) * Math.sqrt(normB)); + } + + // Getters + public int getVocabularySize() { return wordIndex != null ? wordIndex.size() : 0; } + public Set getVocabulary() { return wordIndex != null ? new HashSet<>(wordIndex.keySet()) : null; } + public int getVectorSize() { return vectorSize; } + + public static class Builder { + private int vectorSize = 100; + private int windowSize = 5; + private int minCount = 5; + private int negativeSamples = 5; + private double learningRate = 0.025; + private int epochs = 5; + private long seed = 42; + + public Builder vectorSize(int size) { this.vectorSize = size; return this; } + public Builder windowSize(int size) { this.windowSize = size; return this; } + public Builder minCount(int count) { this.minCount = count; return this; } + public Builder negativeSamples(int samples) { this.negativeSamples = samples; return this; } + public Builder learningRate(double lr) { this.learningRate = lr; return this; } + public Builder epochs(int epochs) { this.epochs = epochs; return this; } + public Builder seed(long seed) { this.seed = seed; return this; } + + public Word2Vec build() { return new Word2Vec(this); } + } +} diff --git a/src/main/java/io/github/yasmramos/mindforge/timeseries/ARIMA.java b/src/main/java/io/github/yasmramos/mindforge/timeseries/ARIMA.java new file mode 100644 index 0000000..3eb5bca --- /dev/null +++ b/src/main/java/io/github/yasmramos/mindforge/timeseries/ARIMA.java @@ -0,0 +1,298 @@ +package io.github.yasmramos.mindforge.timeseries; + +import java.io.Serializable; +import java.util.Arrays; + +/** + * AutoRegressive Integrated Moving Average (ARIMA) model for time series forecasting. + * ARIMA(p, d, q) where: + * - p: order of autoregressive part + * - d: degree of differencing + * - q: order of moving average part + */ +public class ARIMA implements Serializable { + private static final long serialVersionUID = 1L; + + private final int p; // AR order + private final int d; // Differencing order + private final int q; // MA order + private final int maxIterations; + private final double tol; + + private double[] arCoefficients; + private double[] maCoefficients; + private double intercept; + private double[] residuals; + private double[] differencedSeries; + private double[] originalSeries; + + private ARIMA(Builder builder) { + this.p = builder.p; + this.d = builder.d; + this.q = builder.q; + this.maxIterations = builder.maxIterations; + this.tol = builder.tol; + } + + /** + * Fit the ARIMA model to the time series data. + * @param series The time series data + */ + public void fit(double[] series) { + this.originalSeries = series.clone(); + + // Apply differencing + this.differencedSeries = difference(series, d); + + // Estimate AR coefficients using Yule-Walker equations + if (p > 0) { + arCoefficients = estimateARCoefficients(differencedSeries, p); + } else { + arCoefficients = new double[0]; + } + + // Calculate residuals from AR model + residuals = calculateResiduals(differencedSeries, arCoefficients); + + // Estimate MA coefficients + if (q > 0) { + maCoefficients = estimateMACoefficients(residuals, q); + } else { + maCoefficients = new double[0]; + } + + // Calculate intercept + intercept = calculateMean(differencedSeries); + } + + /** + * Forecast future values. + * @param steps Number of steps to forecast + * @return Array of forecasted values + */ + public double[] forecast(int steps) { + if (differencedSeries == null) { + throw new IllegalStateException("Model must be fitted before forecasting"); + } + + double[] forecasts = new double[steps]; + double[] extendedSeries = Arrays.copyOf(differencedSeries, differencedSeries.length + steps); + double[] extendedResiduals = Arrays.copyOf(residuals, residuals.length + steps); + + for (int t = 0; t < steps; t++) { + int currentIndex = differencedSeries.length + t; + double forecast = intercept; + + // AR component + for (int i = 0; i < p && i < currentIndex; i++) { + forecast += arCoefficients[i] * extendedSeries[currentIndex - 1 - i]; + } + + // MA component + for (int i = 0; i < q && i < currentIndex; i++) { + forecast += maCoefficients[i] * extendedResiduals[currentIndex - 1 - i]; + } + + extendedSeries[currentIndex] = forecast; + extendedResiduals[currentIndex] = 0; // Assume zero residual for forecasts + forecasts[t] = forecast; + } + + // Reverse differencing + return inverseDifference(forecasts, originalSeries, d); + } + + /** + * Get fitted values for the training data. + * @return Array of fitted values + */ + public double[] fittedValues() { + if (differencedSeries == null) { + throw new IllegalStateException("Model must be fitted first"); + } + + double[] fitted = new double[differencedSeries.length]; + + for (int t = 0; t < differencedSeries.length; t++) { + double value = intercept; + + // AR component + for (int i = 0; i < p && i < t; i++) { + value += arCoefficients[i] * differencedSeries[t - 1 - i]; + } + + // MA component + for (int i = 0; i < q && i < t; i++) { + value += maCoefficients[i] * residuals[t - 1 - i]; + } + + fitted[t] = value; + } + + return inverseDifferenceInPlace(fitted, originalSeries, d); + } + + private double[] difference(double[] series, int order) { + double[] result = series.clone(); + for (int i = 0; i < order; i++) { + double[] temp = new double[result.length - 1]; + for (int j = 1; j < result.length; j++) { + temp[j - 1] = result[j] - result[j - 1]; + } + result = temp; + } + return result; + } + + private double[] inverseDifference(double[] forecasts, double[] original, int order) { + if (order == 0) { + return forecasts; + } + + double[] result = forecasts.clone(); + double lastValue = original[original.length - 1]; + + for (int d = 0; d < order; d++) { + for (int i = 0; i < result.length; i++) { + result[i] = result[i] + lastValue; + lastValue = result[i]; + } + lastValue = original[original.length - 1]; + } + + return result; + } + + private double[] inverseDifferenceInPlace(double[] fitted, double[] original, int order) { + if (order == 0) { + return fitted; + } + + double[] result = new double[original.length]; + int offset = original.length - fitted.length; + + for (int i = 0; i < offset; i++) { + result[i] = original[i]; + } + + for (int i = 0; i < fitted.length; i++) { + result[offset + i] = fitted[i] + original[offset + i - 1]; + } + + return result; + } + + private double[] estimateARCoefficients(double[] series, int order) { + // Yule-Walker equations + double[] r = new double[order + 1]; + int n = series.length; + double mean = calculateMean(series); + + // Calculate autocorrelations + for (int k = 0; k <= order; k++) { + double sum = 0; + for (int t = k; t < n; t++) { + sum += (series[t] - mean) * (series[t - k] - mean); + } + r[k] = sum / n; + } + + // Solve Yule-Walker equations using Levinson-Durbin recursion + double[] phi = new double[order]; + double[] phiTemp = new double[order]; + + phi[0] = r[1] / r[0]; + + for (int k = 1; k < order; k++) { + double num = r[k + 1]; + for (int j = 0; j < k; j++) { + num -= phi[j] * r[k - j]; + } + + double den = r[0]; + for (int j = 0; j < k; j++) { + den -= phi[j] * r[j + 1]; + } + + phiTemp[k] = num / den; + + for (int j = 0; j < k; j++) { + phiTemp[j] = phi[j] - phiTemp[k] * phi[k - 1 - j]; + } + + System.arraycopy(phiTemp, 0, phi, 0, k + 1); + } + + return phi; + } + + private double[] estimateMACoefficients(double[] residuals, int order) { + // Simple estimation using autocorrelation of residuals + double[] theta = new double[order]; + double mean = calculateMean(residuals); + int n = residuals.length; + + double var = 0; + for (double r : residuals) { + var += (r - mean) * (r - mean); + } + var /= n; + + for (int k = 0; k < order; k++) { + double cov = 0; + for (int t = k + 1; t < n; t++) { + cov += (residuals[t] - mean) * (residuals[t - k - 1] - mean); + } + theta[k] = (cov / n) / var; + } + + return theta; + } + + private double[] calculateResiduals(double[] series, double[] arCoef) { + double[] residuals = new double[series.length]; + double mean = calculateMean(series); + + for (int t = 0; t < series.length; t++) { + double predicted = mean; + for (int i = 0; i < arCoef.length && i < t; i++) { + predicted += arCoef[i] * (series[t - 1 - i] - mean); + } + residuals[t] = series[t] - predicted; + } + + return residuals; + } + + private double calculateMean(double[] data) { + double sum = 0; + for (double v : data) { + sum += v; + } + return sum / data.length; + } + + // Getters + public double[] getARCoefficients() { return arCoefficients != null ? arCoefficients.clone() : null; } + public double[] getMACoefficients() { return maCoefficients != null ? maCoefficients.clone() : null; } + public double getIntercept() { return intercept; } + public int getP() { return p; } + public int getD() { return d; } + public int getQ() { return q; } + + public static class Builder { + private int p = 1; + private int d = 0; + private int q = 1; + private int maxIterations = 100; + private double tol = 1e-6; + + public Builder p(int p) { this.p = p; return this; } + public Builder d(int d) { this.d = d; return this; } + public Builder q(int q) { this.q = q; return this; } + public Builder maxIterations(int maxIterations) { this.maxIterations = maxIterations; return this; } + public Builder tol(double tol) { this.tol = tol; return this; } + + public ARIMA build() { return new ARIMA(this); } + } +} diff --git a/src/main/java/io/github/yasmramos/mindforge/timeseries/ExponentialSmoothing.java b/src/main/java/io/github/yasmramos/mindforge/timeseries/ExponentialSmoothing.java new file mode 100644 index 0000000..f0eafef --- /dev/null +++ b/src/main/java/io/github/yasmramos/mindforge/timeseries/ExponentialSmoothing.java @@ -0,0 +1,229 @@ +package io.github.yasmramos.mindforge.timeseries; + +import java.io.Serializable; +import java.util.Arrays; + +/** + * Exponential Smoothing methods for time series forecasting. + * Supports Simple, Double (Holt), and Triple (Holt-Winters) exponential smoothing. + */ +public class ExponentialSmoothing implements Serializable { + private static final long serialVersionUID = 1L; + + public enum Method { + SIMPLE, // Single exponential smoothing + DOUBLE, // Holt's linear trend + TRIPLE // Holt-Winters with seasonality + } + + public enum SeasonalType { + ADDITIVE, + MULTIPLICATIVE + } + + private final Method method; + private final SeasonalType seasonalType; + private final double alpha; // Level smoothing + private final double beta; // Trend smoothing + private final double gamma; // Seasonal smoothing + private final int seasonalPeriod; + + private double level; + private double trend; + private double[] seasonal; + private double[] fittedValues; + private double[] originalSeries; + + private ExponentialSmoothing(Builder builder) { + this.method = builder.method; + this.seasonalType = builder.seasonalType; + this.alpha = builder.alpha; + this.beta = builder.beta; + this.gamma = builder.gamma; + this.seasonalPeriod = builder.seasonalPeriod; + } + + /** + * Fit the exponential smoothing model to the time series data. + * @param series The time series data + */ + public void fit(double[] series) { + this.originalSeries = series.clone(); + + switch (method) { + case SIMPLE: + fitSimple(series); + break; + case DOUBLE: + fitDouble(series); + break; + case TRIPLE: + fitTriple(series); + break; + } + } + + private void fitSimple(double[] series) { + int n = series.length; + fittedValues = new double[n]; + + // Initialize level with first observation + level = series[0]; + fittedValues[0] = level; + + for (int t = 1; t < n; t++) { + double prevLevel = level; + level = alpha * series[t] + (1 - alpha) * prevLevel; + fittedValues[t] = prevLevel; + } + } + + private void fitDouble(double[] series) { + int n = series.length; + fittedValues = new double[n]; + + // Initialize level and trend + level = series[0]; + trend = series[1] - series[0]; + fittedValues[0] = level; + fittedValues[1] = level + trend; + + for (int t = 2; t < n; t++) { + double prevLevel = level; + double prevTrend = trend; + + level = alpha * series[t] + (1 - alpha) * (prevLevel + prevTrend); + trend = beta * (level - prevLevel) + (1 - beta) * prevTrend; + + fittedValues[t] = prevLevel + prevTrend; + } + } + + private void fitTriple(double[] series) { + int n = series.length; + int m = seasonalPeriod; + + if (n < 2 * m) { + throw new IllegalArgumentException("Series too short for seasonal period. Need at least " + (2 * m) + " observations."); + } + + fittedValues = new double[n]; + seasonal = new double[m]; + + // Initialize level as average of first season + level = 0; + for (int i = 0; i < m; i++) { + level += series[i]; + } + level /= m; + + // Initialize trend + double sum1 = 0, sum2 = 0; + for (int i = 0; i < m; i++) { + sum1 += series[i]; + sum2 += series[m + i]; + } + trend = (sum2 - sum1) / (m * m); + + // Initialize seasonal components + if (seasonalType == SeasonalType.ADDITIVE) { + for (int i = 0; i < m; i++) { + seasonal[i] = series[i] - level; + } + } else { + for (int i = 0; i < m; i++) { + seasonal[i] = series[i] / level; + } + } + + // Fit the model + for (int t = 0; t < n; t++) { + int seasonIndex = t % m; + double prevLevel = level; + double prevTrend = trend; + double prevSeasonal = seasonal[seasonIndex]; + + if (seasonalType == SeasonalType.ADDITIVE) { + level = alpha * (series[t] - prevSeasonal) + (1 - alpha) * (prevLevel + prevTrend); + trend = beta * (level - prevLevel) + (1 - beta) * prevTrend; + seasonal[seasonIndex] = gamma * (series[t] - level) + (1 - gamma) * prevSeasonal; + fittedValues[t] = prevLevel + prevTrend + prevSeasonal; + } else { + level = alpha * (series[t] / prevSeasonal) + (1 - alpha) * (prevLevel + prevTrend); + trend = beta * (level - prevLevel) + (1 - beta) * prevTrend; + seasonal[seasonIndex] = gamma * (series[t] / level) + (1 - gamma) * prevSeasonal; + fittedValues[t] = (prevLevel + prevTrend) * prevSeasonal; + } + } + } + + /** + * Forecast future values. + * @param steps Number of steps to forecast + * @return Array of forecasted values + */ + public double[] forecast(int steps) { + if (fittedValues == null) { + throw new IllegalStateException("Model must be fitted before forecasting"); + } + + double[] forecasts = new double[steps]; + + switch (method) { + case SIMPLE: + Arrays.fill(forecasts, level); + break; + case DOUBLE: + for (int h = 1; h <= steps; h++) { + forecasts[h - 1] = level + h * trend; + } + break; + case TRIPLE: + for (int h = 1; h <= steps; h++) { + int seasonIndex = (originalSeries.length + h - 1) % seasonalPeriod; + if (seasonalType == SeasonalType.ADDITIVE) { + forecasts[h - 1] = level + h * trend + seasonal[seasonIndex]; + } else { + forecasts[h - 1] = (level + h * trend) * seasonal[seasonIndex]; + } + } + break; + } + + return forecasts; + } + + /** + * Get fitted values for the training data. + * @return Array of fitted values + */ + public double[] fittedValues() { + return fittedValues != null ? fittedValues.clone() : null; + } + + // Getters + public double getLevel() { return level; } + public double getTrend() { return trend; } + public double[] getSeasonal() { return seasonal != null ? seasonal.clone() : null; } + public double getAlpha() { return alpha; } + public double getBeta() { return beta; } + public double getGamma() { return gamma; } + + public static class Builder { + private Method method = Method.SIMPLE; + private SeasonalType seasonalType = SeasonalType.ADDITIVE; + private double alpha = 0.3; + private double beta = 0.1; + private double gamma = 0.1; + private int seasonalPeriod = 12; + + public Builder method(Method method) { this.method = method; return this; } + public Builder seasonalType(SeasonalType type) { this.seasonalType = type; return this; } + public Builder alpha(double alpha) { this.alpha = alpha; return this; } + public Builder beta(double beta) { this.beta = beta; return this; } + public Builder gamma(double gamma) { this.gamma = gamma; return this; } + public Builder seasonalPeriod(int period) { this.seasonalPeriod = period; return this; } + + public ExponentialSmoothing build() { return new ExponentialSmoothing(this); } + } +} diff --git a/src/main/java/io/github/yasmramos/mindforge/timeseries/SimpleMovingAverage.java b/src/main/java/io/github/yasmramos/mindforge/timeseries/SimpleMovingAverage.java new file mode 100644 index 0000000..cb610e6 --- /dev/null +++ b/src/main/java/io/github/yasmramos/mindforge/timeseries/SimpleMovingAverage.java @@ -0,0 +1,107 @@ +package io.github.yasmramos.mindforge.timeseries; + +import java.io.Serializable; + +/** + * Simple Moving Average for time series smoothing and forecasting. + */ +public class SimpleMovingAverage implements Serializable { + private static final long serialVersionUID = 1L; + + private final int window; + private double[] originalSeries; + private double[] smoothedSeries; + private double lastAverage; + + public SimpleMovingAverage(int window) { + if (window < 1) { + throw new IllegalArgumentException("Window must be at least 1"); + } + this.window = window; + } + + /** + * Fit the moving average to the time series data. + * @param series The time series data + */ + public void fit(double[] series) { + if (series.length < window) { + throw new IllegalArgumentException("Series length must be at least equal to window size"); + } + + this.originalSeries = series.clone(); + int n = series.length; + smoothedSeries = new double[n - window + 1]; + + // Calculate first moving average + double sum = 0; + for (int i = 0; i < window; i++) { + sum += series[i]; + } + smoothedSeries[0] = sum / window; + + // Calculate remaining using sliding window + for (int i = 1; i < smoothedSeries.length; i++) { + sum = sum - series[i - 1] + series[i + window - 1]; + smoothedSeries[i] = sum / window; + } + + lastAverage = smoothedSeries[smoothedSeries.length - 1]; + } + + /** + * Forecast future values. + * Simple MA forecasts the last average for all future steps. + * @param steps Number of steps to forecast + * @return Array of forecasted values + */ + public double[] forecast(int steps) { + if (smoothedSeries == null) { + throw new IllegalStateException("Model must be fitted before forecasting"); + } + + double[] forecasts = new double[steps]; + for (int i = 0; i < steps; i++) { + forecasts[i] = lastAverage; + } + return forecasts; + } + + /** + * Get smoothed values. + * @return Array of smoothed values + */ + public double[] getSmoothedSeries() { + return smoothedSeries != null ? smoothedSeries.clone() : null; + } + + /** + * Transform a series using the moving average. + * @param series The series to transform + * @return Smoothed series + */ + public double[] transform(double[] series) { + if (series.length < window) { + throw new IllegalArgumentException("Series length must be at least equal to window size"); + } + + int n = series.length; + double[] result = new double[n - window + 1]; + + double sum = 0; + for (int i = 0; i < window; i++) { + sum += series[i]; + } + result[0] = sum / window; + + for (int i = 1; i < result.length; i++) { + sum = sum - series[i - 1] + series[i + window - 1]; + result[i] = sum / window; + } + + return result; + } + + public int getWindow() { return window; } + public double getLastAverage() { return lastAverage; } +} diff --git a/src/test/java/io/github/yasmramos/mindforge/nlp/NLPTest.java b/src/test/java/io/github/yasmramos/mindforge/nlp/NLPTest.java new file mode 100644 index 0000000..951f217 --- /dev/null +++ b/src/test/java/io/github/yasmramos/mindforge/nlp/NLPTest.java @@ -0,0 +1,292 @@ +package io.github.yasmramos.mindforge.nlp; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +import java.util.*; + +/** + * Tests for NLP classes. + */ +public class NLPTest { + + @Test + public void testCountVectorizerFitTransform() { + String[] docs = { + "the cat sat on the mat", + "the dog sat on the log", + "the cat and the dog" + }; + + CountVectorizer cv = new CountVectorizer.Builder().build(); + double[][] result = cv.fitTransform(docs); + + assertNotNull(result); + assertEquals(3, result.length); + assertTrue(cv.getVocabularySize() > 0); + } + + @Test + public void testCountVectorizerVocabulary() { + String[] docs = {"hello world", "hello java"}; + + CountVectorizer cv = new CountVectorizer.Builder().build(); + cv.fit(docs); + + Map vocab = cv.getVocabulary(); + assertNotNull(vocab); + assertTrue(vocab.containsKey("hello")); + assertTrue(vocab.containsKey("world")); + assertTrue(vocab.containsKey("java")); + } + + @Test + public void testCountVectorizerBinary() { + String[] docs = {"hello hello hello world"}; + + CountVectorizer cv = new CountVectorizer.Builder() + .binary(true) + .build(); + double[][] result = cv.fitTransform(docs); + + // With binary=true, all non-zero values should be 1 + for (double val : result[0]) { + assertTrue(val == 0.0 || val == 1.0); + } + } + + @Test + public void testCountVectorizerStopWords() { + String[] docs = {"the quick brown fox"}; + + CountVectorizer cv = new CountVectorizer.Builder() + .stopWords(CountVectorizer.getEnglishStopWords()) + .build(); + cv.fit(docs); + + Map vocab = cv.getVocabulary(); + assertFalse(vocab.containsKey("the")); + assertTrue(vocab.containsKey("quick")); + } + + @Test + public void testCountVectorizerNgrams() { + String[] docs = {"hello world"}; + + CountVectorizer cv = new CountVectorizer.Builder() + .ngramRange(1, 2) + .build(); + cv.fit(docs); + + Map vocab = cv.getVocabulary(); + assertTrue(vocab.containsKey("hello")); + assertTrue(vocab.containsKey("world")); + assertTrue(vocab.containsKey("hello world")); + } + + @Test + public void testCountVectorizerMaxFeatures() { + String[] docs = {"one two three four five six seven"}; + + CountVectorizer cv = new CountVectorizer.Builder() + .maxFeatures(3) + .build(); + cv.fit(docs); + + assertEquals(3, cv.getVocabularySize()); + } + + @Test + public void testTfidfVectorizerFitTransform() { + String[] docs = { + "this is the first document", + "this document is the second document", + "and this is the third one" + }; + + TfidfVectorizer tfidf = new TfidfVectorizer.Builder().build(); + double[][] result = tfidf.fitTransform(docs); + + assertNotNull(result); + assertEquals(3, result.length); + assertTrue(tfidf.getVocabularySize() > 0); + } + + @Test + public void testTfidfVectorizerNormalization() { + String[] docs = {"hello world foo bar"}; + + TfidfVectorizer tfidf = new TfidfVectorizer.Builder() + .norm(TfidfVectorizer.Norm.L2) + .build(); + double[][] result = tfidf.fitTransform(docs); + + // Check L2 norm equals 1 + double norm = 0; + for (double val : result[0]) { + norm += val * val; + } + assertEquals(1.0, Math.sqrt(norm), 0.001); + } + + @Test + public void testTfidfVectorizerNoNorm() { + String[] docs = {"hello world"}; + + TfidfVectorizer tfidf = new TfidfVectorizer.Builder() + .norm(TfidfVectorizer.Norm.NONE) + .build(); + double[][] result = tfidf.fitTransform(docs); + + // Without normalization, values can be > 1 + assertNotNull(result); + } + + @Test + public void testTfidfVectorizerIdfWeights() { + String[] docs = { + "common rare", + "common word", + "common term" + }; + + TfidfVectorizer tfidf = new TfidfVectorizer.Builder() + .norm(TfidfVectorizer.Norm.NONE) + .build(); + tfidf.fit(docs); + + double[] idf = tfidf.getIdfWeights(); + assertNotNull(idf); + + // "common" appears in all docs, should have lower IDF + Map vocab = tfidf.getVocabulary(); + int commonIdx = vocab.get("common"); + int rareIdx = vocab.get("rare"); + + assertTrue(idf[rareIdx] > idf[commonIdx]); + } + + @Test + public void testTfidfVectorizerSublinearTf() { + String[] docs = {"word word word word"}; + + TfidfVectorizer tfidf = new TfidfVectorizer.Builder() + .sublinearTf(true) + .norm(TfidfVectorizer.Norm.NONE) + .build(); + double[][] result = tfidf.fitTransform(docs); + + // With sublinear TF, tf = 1 + log(count) + assertNotNull(result); + } + + @Test + public void testWord2VecFit() { + String[] docs = { + "the king loves the queen", + "the queen loves the king", + "the prince loves the princess", + "the princess loves the prince", + "king and queen rule the kingdom", + "prince and princess live in the castle" + }; + + Word2Vec w2v = new Word2Vec.Builder() + .vectorSize(10) + .windowSize(2) + .minCount(1) + .epochs(10) + .build(); + + w2v.fit(docs); + + assertTrue(w2v.getVocabularySize() > 0); + assertTrue(w2v.getVocabulary().contains("king")); + } + + @Test + public void testWord2VecGetVector() { + String[] docs = {"hello world hello java world"}; + + Word2Vec w2v = new Word2Vec.Builder() + .vectorSize(5) + .minCount(1) + .epochs(5) + .build(); + + w2v.fit(docs); + + double[] vec = w2v.getVector("hello"); + assertNotNull(vec); + assertEquals(5, vec.length); + + // Unknown word should return null + assertNull(w2v.getVector("unknown")); + } + + @Test + public void testWord2VecSimilarity() { + String[] docs = { + "cat dog pet animal", + "cat pet cute", + "dog pet friendly", + "car vehicle drive", + "car vehicle road" + }; + + Word2Vec w2v = new Word2Vec.Builder() + .vectorSize(20) + .windowSize(3) + .minCount(1) + .epochs(50) + .build(); + + w2v.fit(docs); + + double simCatDog = w2v.similarity("cat", "dog"); + assertFalse(Double.isNaN(simCatDog)); + + // Both words should have vectors + assertNotNull(w2v.getVector("cat")); + assertNotNull(w2v.getVector("dog")); + } + + @Test + public void testWord2VecMostSimilar() { + String[] docs = { + "apple banana fruit orange", + "apple fruit sweet", + "banana fruit yellow", + "orange fruit citrus" + }; + + Word2Vec w2v = new Word2Vec.Builder() + .vectorSize(10) + .minCount(1) + .epochs(20) + .build(); + + w2v.fit(docs); + + List> similar = w2v.mostSimilar("apple", 2); + assertNotNull(similar); + assertEquals(2, similar.size()); + } + + @Test + public void testWord2VecUnknownWord() { + String[] docs = {"hello world"}; + + Word2Vec w2v = new Word2Vec.Builder() + .minCount(1) + .build(); + + w2v.fit(docs); + + double sim = w2v.similarity("hello", "unknown"); + assertTrue(Double.isNaN(sim)); + + List> similar = w2v.mostSimilar("unknown", 5); + assertTrue(similar.isEmpty()); + } +} diff --git a/src/test/java/io/github/yasmramos/mindforge/timeseries/TimeSeriesTest.java b/src/test/java/io/github/yasmramos/mindforge/timeseries/TimeSeriesTest.java new file mode 100644 index 0000000..ec97c28 --- /dev/null +++ b/src/test/java/io/github/yasmramos/mindforge/timeseries/TimeSeriesTest.java @@ -0,0 +1,205 @@ +package io.github.yasmramos.mindforge.timeseries; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for Time Series forecasting classes. + */ +public class TimeSeriesTest { + + @Test + public void testARIMAFitPredict() { + // Simple time series with trend + double[] series = new double[50]; + for (int i = 0; i < 50; i++) { + series[i] = 10 + 0.5 * i + Math.sin(i * 0.5) * 2; + } + + ARIMA arima = new ARIMA.Builder() + .p(2) + .d(1) + .q(1) + .build(); + + arima.fit(series); + + double[] forecast = arima.forecast(5); + assertNotNull(forecast); + assertEquals(5, forecast.length); + + // Forecasts should continue the trend + assertTrue(forecast[0] > series[series.length - 1] - 10); + } + + @Test + public void testARIMAFittedValues() { + double[] series = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; + + ARIMA arima = new ARIMA.Builder() + .p(1) + .d(0) + .q(0) + .build(); + + arima.fit(series); + + double[] fitted = arima.fittedValues(); + assertNotNull(fitted); + assertEquals(series.length, fitted.length); + } + + @Test + public void testARIMAGetters() { + ARIMA arima = new ARIMA.Builder() + .p(2) + .d(1) + .q(3) + .build(); + + assertEquals(2, arima.getP()); + assertEquals(1, arima.getD()); + assertEquals(3, arima.getQ()); + } + + @Test + public void testExponentialSmoothingSimple() { + double[] series = {10, 12, 14, 13, 15, 17, 16, 18, 20, 19}; + + ExponentialSmoothing es = new ExponentialSmoothing.Builder() + .method(ExponentialSmoothing.Method.SIMPLE) + .alpha(0.3) + .build(); + + es.fit(series); + + double[] forecast = es.forecast(3); + assertNotNull(forecast); + assertEquals(3, forecast.length); + + // All forecasts should be equal for simple ES + assertEquals(forecast[0], forecast[1], 0.001); + assertEquals(forecast[1], forecast[2], 0.001); + } + + @Test + public void testExponentialSmoothingDouble() { + double[] series = new double[20]; + for (int i = 0; i < 20; i++) { + series[i] = 10 + i * 2; // Linear trend + } + + ExponentialSmoothing es = new ExponentialSmoothing.Builder() + .method(ExponentialSmoothing.Method.DOUBLE) + .alpha(0.8) + .beta(0.2) + .build(); + + es.fit(series); + + double[] forecast = es.forecast(5); + assertNotNull(forecast); + assertEquals(5, forecast.length); + + // Forecasts should continue the trend + assertTrue(forecast[4] > forecast[0]); + } + + @Test + public void testExponentialSmoothingTriple() { + // Series with seasonality (period = 4) + double[] series = new double[24]; + for (int i = 0; i < 24; i++) { + series[i] = 100 + i * 2 + (i % 4) * 10; + } + + ExponentialSmoothing es = new ExponentialSmoothing.Builder() + .method(ExponentialSmoothing.Method.TRIPLE) + .alpha(0.5) + .beta(0.1) + .gamma(0.3) + .seasonalPeriod(4) + .seasonalType(ExponentialSmoothing.SeasonalType.ADDITIVE) + .build(); + + es.fit(series); + + double[] forecast = es.forecast(4); + assertNotNull(forecast); + assertEquals(4, forecast.length); + } + + @Test + public void testExponentialSmoothingFittedValues() { + double[] series = {10, 12, 14, 16, 18, 20}; + + ExponentialSmoothing es = new ExponentialSmoothing.Builder() + .method(ExponentialSmoothing.Method.SIMPLE) + .alpha(0.5) + .build(); + + es.fit(series); + + double[] fitted = es.fittedValues(); + assertNotNull(fitted); + assertEquals(series.length, fitted.length); + } + + @Test + public void testSimpleMovingAverageFit() { + double[] series = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; + + SimpleMovingAverage sma = new SimpleMovingAverage(3); + sma.fit(series); + + double[] smoothed = sma.getSmoothedSeries(); + assertNotNull(smoothed); + assertEquals(8, smoothed.length); // n - window + 1 + + // First average should be (1+2+3)/3 = 2 + assertEquals(2.0, smoothed[0], 0.001); + } + + @Test + public void testSimpleMovingAverageForecast() { + double[] series = {10, 20, 30, 40, 50}; + + SimpleMovingAverage sma = new SimpleMovingAverage(3); + sma.fit(series); + + double[] forecast = sma.forecast(3); + assertNotNull(forecast); + assertEquals(3, forecast.length); + + // All forecasts should equal the last average + double lastAvg = sma.getLastAverage(); + for (double f : forecast) { + assertEquals(lastAvg, f, 0.001); + } + } + + @Test + public void testSimpleMovingAverageTransform() { + double[] series = {2, 4, 6, 8, 10}; + + SimpleMovingAverage sma = new SimpleMovingAverage(2); + double[] result = sma.transform(series); + + assertEquals(4, result.length); + assertEquals(3.0, result[0], 0.001); // (2+4)/2 + assertEquals(5.0, result[1], 0.001); // (4+6)/2 + } + + @Test + public void testSimpleMovingAverageInvalidWindow() { + assertThrows(IllegalArgumentException.class, () -> new SimpleMovingAverage(0)); + } + + @Test + public void testSimpleMovingAverageTooShortSeries() { + SimpleMovingAverage sma = new SimpleMovingAverage(5); + double[] series = {1, 2, 3}; + + assertThrows(IllegalArgumentException.class, () -> sma.fit(series)); + } +}