Repository: GlobalMaksimum/sadedegel (language: python)

Issue:
Implement Alternative Product Sentiment Dataset
- Current one has noisy neutral label and no benchmark.
- [Paper link](https://sentic.net/wisdom2013pechenizkiy.pdf) that used the dataset. 
- [Dataset link](https://github.com/FurkanGozukara/Sentiment-Analysis/tree/master/Turkish_Product_Reviews_by_Demirtas_and_Pechenizkiy_2013)


Discussion / hints:

Fix patch:
diff --git a/sadedegel/dataset/README.md b/sadedegel/dataset/README.md
index 49808518..b9c54f24 100644
--- a/sadedegel/dataset/README.md
+++ b/sadedegel/dataset/README.md
@@ -250,7 +250,7 @@ from sadedegel.dataset.customer_review import CLASS_VALUES
 CLASS_VALUES[1]
 
 # Out[2]: 'alisveris'
-
+```
 ## `telco_sentiment`
 
 Telecom Sentiment dataset is an open sourced tweet sentiment corpus that includes tweets referring to a certain telecom
@@ -295,8 +295,51 @@ test = load_movie_sentiment_test()
 test_label = load_movie_sentiment_test_label()
 ```
 
+## `categorized_product_sentiment`
+
+This corpus contains 5600 instances of customer product reviews from E-commerce sites. Reviews contain two sets of class labels. First label is `sentiment_class` which contains `[POSITIVE, NEGATIVE]` sentiment of the review. Second label is `product_category` which contains `["Kitchen", "DVD", "Books", "Electronics"]` as the category of the product being reviewed. Each product category contains 1400 instances. The dataset is material to the research [paper](https://sentic.net/wisdom2013pechenizkiy.pdf) by Demirtaş and Pechenizkiy.
+
+Number of instances in each `product_category` grouped by `sentiment_class`:
+
+|             | `Kitchen` | `Books` | `DVD`    | `Electronics` |
+| :---        | :----:  | :---: |  :---: | :---:  |
+| **`POSITIVE`**    | 700     | 700   |  700   |  700   |
+| **`NEGATIVE`**    | 700     | 700   |  700   |  700   |
+
+```python
+import pandas as pd
+
+from sadedegel.dataset.categorized_product_sentiment import load_categorized_product_sentiment_train
+from sadedegel.dataset.categorized_product_sentiment import SENTIMENT_CLASS_VALUES, PRODUCT_CATEGORIES
+
+raw = load_categorized_product_sentiment_train()
+
+next(raw)
+
+# Out [0]: {'id': 'bac3a153-397e-4c90-aaec-c9dfa51a9784', 'text': 'ürünün tedarik edilme süreci biraz uzasa da beklediğime değdi, set cam ağırlıklı olmasına rağmen sağlam olarak elime ulaştı. almayı düşünenlere tavsiye ederim, set beklentilerinizi karşılıyor...', 'product_category': 0, 'sentiment_class': 0}
+
+
+df = pd.DataFrame().from_records(raw)
+
+# Load Subsets
+
+dvd = load_categorized_product_sentiment_train('DVD')
+kitchen = load_categorized_product_sentiment_train('Kitchen')
+books = load_categorized_product_sentiment_train('Books')
+electronics = load_categorized_product_sentiment_train('Electronics')
+
+# Mappings
+
+SENTIMENT_CLASS_VALUES[0]
+# Out [0]: 'POSITIVE'
+
+PRODUCT_CATEGORIES[0]
+# Out [0]: 'Kitchen'
+```
+
+
 [scraper]: https://github.com/GlobalMaksimum/sadedegel-scraper
 
 [Taner Sezer]: https://github.com/tanerim
 
-[tscorpus]: tscorpus/
\ No newline at end of file
+[tscorpus]: tscorpus/
diff --git a/sadedegel/dataset/categorized_product_sentiment/__init__.py b/sadedegel/dataset/categorized_product_sentiment/__init__.py
new file mode 100644
index 00000000..982c7f12
--- /dev/null
+++ b/sadedegel/dataset/categorized_product_sentiment/__init__.py
@@ -0,0 +1,2 @@
+from ._core import load_categorized_product_sentiment_train
+from ._core import SENTIMENT_CLASS_VALUES, PRODUCT_CATEGORIES, CORPUS_SIZE
diff --git a/sadedegel/dataset/categorized_product_sentiment/__main__.py b/sadedegel/dataset/categorized_product_sentiment/__main__.py
new file mode 100644
index 00000000..3344f382
--- /dev/null
+++ b/sadedegel/dataset/categorized_product_sentiment/__main__.py
@@ -0,0 +1,90 @@
+import gzip
+import os.path
+import sys
+from itertools import tee
+from pathlib import Path
+from shutil import copyfileobj
+
+import boto3
+import click
+from loguru import logger
+from rich.console import Console
+from smart_open import open
+
+from ._core import load_categorized_product_sentiment_train, CORPUS_SIZE
+
+console = Console()
+
+logger.disable("sadedegel")
+
+
+@click.group(help="Categorized Product Sentiment Dataset Commandline")
+def cli():
+    pass
+
+
+@cli.command()
+@click.option("--access-key", help="Access Key ID to download dataset.", prompt=True,
+              default=lambda: os.environ.get('sadedegel_access_key', ''))
+@click.option("--secret-key", help="Secret Key ID to download dataset.", prompt=True,
+              default=lambda: os.environ.get('sadedegel_secret_key', ''))
+@click.option("--data_home", '-d', help="Data home directory", default="~/.sadedegel_data")
+def download(access_key, secret_key, data_home):
+    """Download twitter sentiment corpus from cloud with your key."""
+
+    data_home = Path(os.path.expanduser(data_home)) / 'categorized_product_sentiment'
+    data_home.mkdir(parents=True, exist_ok=True)
+    console.print(f"Data directory for categorized product sentiment corpus {data_home}")
+
+    transport_params = {
+        'session': boto3.Session(aws_access_key_id=access_key,
+                                 aws_secret_access_key=secret_key),
+        'resource_kwargs': {
+            'endpoint_url': 'https://storage.googleapis.com',
+        }
+    }
+
+    url = f"s3://sadedegel/dataset/categorized_product_sentiment.csv.gz"
+
+    with open(url, 'rb', transport_params=transport_params) as fp, gzip.open(data_home / os.path.basename(url),
+                                                                             "wb") as wp:
+        copyfileobj(fp, wp)
+
+
+@cli.command()
+def validate():
+    """Sanity check on corpus
+    """
+    with console.status("[bold yellow]Validating train"):
+        train = load_categorized_product_sentiment_train()
+
+        train_clone, train_s, train_c = tee(train, 3)
+
+        n_train = sum(1 for _ in train_clone)
+        sentiment_categories = set([row['sentiment_class'] for row in train_s])
+        product_categories = set([row['product_category'] for row in train_c])
+
+        if n_train == CORPUS_SIZE:
+            console.log("Cardinality check [yellow]DONE[/yellow]")
+        else:
+            console.log(f"Cardinality check [red]FAILED[/red]")
+            console.log(f"|Categorized sentiment (train)| : {n_train} ({CORPUS_SIZE} expected)")
+            sys.exit(1)
+
+        if sentiment_categories == {0, 1}:
+            console.log("Sentiment Label check [yellow]DONE[/yellow]")
+        else:
+            console.log("Sentiment Label check [red]FAILED[/red]")
+            console.log(f"\tCatgorized product sentiment classes : {sentiment_categories} ({set(['POSITIVE', 'NEGATIVE'])} expected)")
+            sys.exit(1)
+
+        if product_categories == {0, 1, 2, 3}:
+            console.log("Product Category Label check [yellow]DONE[/yellow]")
+        else:
+            console.log("Category Label check [red]FAILED[/red]")
+            console.log(f"\tCatgorized product category classes : {product_categories} ({set(['Kitchen', 'DVD', 'Books', 'Electronics'])} expected)")
+            sys.exit(1)
+
+
+if __name__ == "__main__":
+    cli()
diff --git a/sadedegel/dataset/categorized_product_sentiment/_core.py b/sadedegel/dataset/categorized_product_sentiment/_core.py
new file mode 100644
index 00000000..d8f378a9
--- /dev/null
+++ b/sadedegel/dataset/categorized_product_sentiment/_core.py
@@ -0,0 +1,79 @@
+import csv
+import gzip
+from pathlib import Path
+from rich.console import Console
+
+from typing import Union, List, Iterator
+
+SENTIMENT_CLASS_VALUES = ['POSITIVE', 'NEGATIVE']
+PRODUCT_CATEGORIES = ['Kitchen', 'DVD', 'Books', 'Electronics']
+CORPUS_SIZE = 5600
+
+console = Console()
+
+__general_download_message__ = """Ensure that you have properly downloaded categorized product sentiment corpus using
+
+            python -m sadedegel.dataset.tweet_sentiment download --access-key xxx --secret-key xxxx
+
+        Unfortunately due to data licensing issues we could not share data publicly. 
+        Get in touch with sadedegel team to obtain a download key.
+        """
+
+
+def check_directory_structure(path: str) -> bool:
+    base_dir = Path(path).expanduser()
+
+    cat_prod_sentiment_dir = base_dir / 'categorized_product_sentiment'
+
+    if not base_dir.exists():
+        console.log(f"Dataset base directory ([bold red]{base_dir}[/bold red]) does not exist")
+
+    elif not cat_prod_sentiment_dir.exists():
+        console.log(
+            f"Tweet sentiment directory ([bold red]{cat_prod_sentiment_dir}[/bold red]) does not exist")
+
+    else:
+        return True
+
+    console.log(__general_download_message__)
+
+    return False
+
+
+def load_categorized_product_sentiment_train(data_home="~/.sadedegel_data",
+                                             categories: Union[None, List[str], str] = None) -> Iterator[dict]:
+    """
+
+    @param data_home: Sadedegel data directory base. Default to be ~/.sadedegel_data
+    @param categories:
+        If None (default), load all the categories.
+        If not None, list of category names (or a single category) to load (other categories
+        ignored).
+    @return: Iterator of dictionary
+    """
+
+    if not check_directory_structure(data_home):
+        raise Exception("Categorized Product Corpus validation error")
+
+    train_csv = Path(data_home).expanduser() / "categorized_product_sentiment"
+    train_csv = train_csv / "categorized_product_sentiment.csv.gz"
+
+    if categories is None:
+        filtered_categories = PRODUCT_CATEGORIES
+    elif isinstance(categories, str):
+        filtered_categories = [categories]
+    elif isinstance(categories, list):
+        filtered_categories = categories
+    else:
+        raise ValueError(f"categories of type {type(categories)} is invalid.")
+
+    with gzip.open(train_csv, "rt") as csvfile:
+        rd = csv.DictReader(csvfile)
+
+        for rec in rd:
+            d = dict(id=rec['text_uuid'], text=rec['text'],
+                     product_category=PRODUCT_CATEGORIES.index(rec['category']),
+                     sentiment_class=SENTIMENT_CLASS_VALUES.index(rec['sentiment_class']))
+
+            if rec['category'] in filtered_categories:
+                yield d


Test patch:
diff --git a/tests/datasets/context.py b/tests/datasets/context.py
index 839954eb..9a3ddc4f 100644
--- a/tests/datasets/context.py
+++ b/tests/datasets/context.py
@@ -11,6 +11,7 @@
 from sadedegel.dataset.product_sentiment import CLASS_VALUES as PS_CLASS_VALUES # noqa # pylint: disable=unused-import, wrong-import-position
 from sadedegel.dataset.telco_sentiment import load_telco_sentiment_train, load_telco_sentiment_test, load_telco_sentiment_test_label # noqa # pylint: disable=unused-import, wrong-import-position
 from sadedegel.dataset.telco_sentiment import CLASS_VALUES as TELCO_CLASS_VALUES # noqa # pylint: disable=unused-import, wrong-import-position
+from sadedegel.dataset.categorized_product_sentiment import load_categorized_product_sentiment_train, SENTIMENT_CLASS_VALUES, PRODUCT_CATEGORIES # noqa # pylint: disable=unused-import, wrong-import-position
 from sadedegel.dataset import movie_sentiment # noqa # pylint: disable=unused-import, wrong-import-position
 from sadedegel.bblock.cli.__main__ import tok_eval # noqa # pylint: disable=unused-import, wrong-import-position
 from sadedegel.dataset import util # noqa # pylint: disable=unused-import, wrong-import-position
diff --git a/tests/datasets/test_categorized_product_sentiment.py b/tests/datasets/test_categorized_product_sentiment.py
new file mode 100644
index 00000000..ad468a81
--- /dev/null
+++ b/tests/datasets/test_categorized_product_sentiment.py
@@ -0,0 +1,46 @@
+import pytest
+from .context import load_categorized_product_sentiment_train, SENTIMENT_CLASS_VALUES, PRODUCT_CATEGORIES
+
+
+def test_data_load():
+    data = load_categorized_product_sentiment_train()
+
+    count = 0
+    for row in data:
+        assert any(key in row.keys() for key in ['id', 'text', 'sentiment_class', 'product_category'])
+        assert isinstance(row['id'], str)
+        assert isinstance(row['text'], str)
+
+        count += 1
+
+    assert count == 5600
+
+
+@pytest.mark.parametrize('subset', ['Kitchen', 'DVD', 'Books', 'Electronics'])
+def test_data_subset_str(subset):
+    data = load_categorized_product_sentiment_train(categories=subset)
+    count = 0
+    for row in data:
+        assert any(key in row.keys() for key in ['id', 'text', 'sentiment_class', 'product_category'])
+        assert isinstance(row['id'], str)
+        assert isinstance(row['text'], str)
+        assert PRODUCT_CATEGORIES[row['product_category']] == subset
+
+        count += 1
+
+    assert count == 1400
+
+
+def test_data_subset_list():
+    lov = ['Kitchen', 'DVD']
+    data = load_categorized_product_sentiment_train(categories=lov)
+    count = 0
+    for row in data:
+        assert any(key in row.keys() for key in ['id', 'text', 'sentiment_class', 'product_category'])
+        assert isinstance(row['id'], str)
+        assert isinstance(row['text'], str)
+        assert PRODUCT_CATEGORIES[row['product_category']] in lov
+
+        count += 1
+
+    assert count == 2800